Compare commits

...

19 Commits

Author SHA1 Message Date
2113bfc125 fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器
生产实测逐渠道排查(2026-10-19):

* qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串;
  qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test
  走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包
  {access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。

* trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧),
  transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报
  invalid character 'e'。补 aggregate_solo_sse 聚合。

* buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制
  stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。

* provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为
  tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。

* opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML);
  兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。
  opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹
  分桶),保留免费层门禁四要素。

* 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的
  隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。

* channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号
  闪现在 opencode 卡片下的渲染竞态。

* channels request_end hook:挂接丢失的 isModelGone/recordDeadModel ——
  真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进
  deadModels,不再每轮 404 仍可选中。
2026-10-19 22:32:18 +08:00
c9f34538ec fix(codearts): 识别 InferHub 未注册模型 2026-10-19 18:02:30 +08:00
12bfd53f16 fix: 收口渠道源迁移与测试页合并 2026-10-19 17:21:22 +08:00
25970a2135 refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据
用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」

## 一、删除 priority(它从未参与跨源调度)

`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。

- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority

## 二、一通道 = 一个适配器 = 一个源(channels 插件)

旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效

新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
  (内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
  跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
  时给当前账号打 termErr,下次选号跳过;成功则清标记。
  ⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发

## 三、discover 支持用已存凭据(真实缺口)

刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。

- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
  做探测,那就成了 SSRF/密钥外带);响应不回显 key

## 四、image_models 显式声明(补元数据缺失)

`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。

不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。

## 五、UI:对话与生图合并为一个导航项

两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。

## 测试

- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
  切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
  一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
  (TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
  (验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"

gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。

BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。
2026-10-19 16:26:59 +08:00
6f2d4dc6eb fix(plugins): 三个坏渠道 + 三插件图标 + 计费可见性
用户报障:「检查一下,同时修复坏渠道,还有插件不是可以自己决定注册
哪些渠道吗?你怎么不修一下,还有除了 billing 以外的两个插件都没有正确的
图标,而且另外两个插件计费也不正确」

## 一、坏渠道(都在插件里修,不动内核)

### 1. zcode —— base_url 吞掉了 endpoint 路径(404)
`base_url = https://zcode.z.ai/api/v1/zcode-plan/anthropic`
`endpoint = /api/v1/zcode-plan/anthropic/v1/messages`
内核 URL = base_url + endpoint(provider.go ChatURL)⇒ 路径拼两遍:
`.../anthropic/api/v1/zcode-plan/anthropic/v1/messages` → 404 page not found。
修:base_url 只留 host,完整路径进 endpoint。与 v2.0.30
「12 平台 base_url 侵占 endpoint」同族。

### 2. minimax —— 打到 Web 前端(404「简单指令,无限可能」)
base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions,那是 Web 前端。
照参考 minimax-product.ts:
  MINIMAX_INFER_PATH = /mavis/api/v1/llm/v1/messages(**Anthropic Messages**)
实测(2026-10-19):
  /mavis/api/v1/llm/v1/messages   → 401 invalid access token(路径对)
  裸 /chat/completions            → 404 page not found
  /mavis/api/v1/chat/completions  → 404
修:endpoint 指向真实推理路径 + adapter 改 anthropic(第二层:协议形状)。
修后从 404 变 401(凭据问题,不再是路由问题)。

### 3. codearts —— 只声明"可续期"却没声明"怎么续"
`account_refresh` 走 `refreshCredential()`,要求 `plat.refresh.url` 或
`refresh_fn` 存在。codearts 只有 accountView 里硬编码的 `refreshable=true`
⇒ 恒返回「该平台未声明续期方式」,自动续期链空转,日志反复
`模型刷新失败 HTTP 400`。而 STS security_token 只有 1 小时寿命,过期后
每个请求都是 `APIG.0602: the security token has expired`。

修法(三层,每层都有独立的坑):
a) 加 `refresh_fn` 钩子。华为云 STS 要 **DPoP 头**(jwk 在凭据里),
   声明式 REFRESH_STYLES 拿不到私钥 ⇒ 必须用钩子挂已有的
   `codeartsRequestToken`。
b) **JWK → PEM 重建**。`codeartsNewDpop()` 返回 {pem,public,private},但
   凭据只持久化了 private(JWK),pem 是登录临时文件。而 `codeartsDpopSign`
   走 openssl 命令行要 `key.pem` 字符串 ⇒ 直接传会报
   `bad argument #1 to 'write' (string expected, got nil)`。
   手工组 SEC1 `EC PRIVATE KEY` 的 DER(INTEGER 1 / OCTET STRING d /
   [0] OID prime256v1 / [1] BIT STRING 04||X||Y)再 PEM 包装。
c) 绑定放在**文件末尾的绑定循环**(与 claim_fn 同处)。在 PLATFORMS 表里
   内联会闭包捕获当时的全局 nil —— 与 trae headers_fn 那次同一坑。

## 二、插件图标(3 个坏 1 个)

billing 有 SVG 但**缺 stroke/fill=currentColor**(深色主题下不可见);
channels 声明 `icon = ""`(侧边栏空白);quota 连 icon 字段都没有。
统一成 inline SVG + `stroke="currentColor"`(照参考 src/sponsor.js:52 的
图标工厂形状)。

## 三、判据(4 条,全部做过变异验证)

- `TestChannelBaseURLDoesNotSwallowEndpoint`:base_url 里的路径段不得在
  endpoint 重复出现。**走真实加载插件**而非正则读源码 —— PLATFORMS 是
  Lua 表,有注释/条件拼装/末尾绑定循环,正则会给出假绿。
- `TestMinimaxUsesAnthropicProtocolAndRealInferPath`:路径 + 协议双断言。
- `TestCodeartsDeclaresRefreshHook`:分开报 has_refresh_fn / has_refresh_decl。
  只看 refreshable 不够 —— 那是"凭据类型可续",与"平台实现了续期"是两件事。
- `TestCodeartsRebuildsPemFromJwkAndSigns`:**必须真签名**。DER 结构错、
  长度错、base64 换行错都得到"非空但无效"的 PEM,只断言非空等于没验。
- `TestB64urlDecodeHandlesLongInput`:补一个真实测漏 —— 我的 b64urlDecode
  第一版在输出字节后没清高位(缺 `buf = buf % (2^nbits)`),43 字符的 JWK
  字段(32 字节)测出来**完全正确**,于是 PEM 判据在变异后依然全绿。
  只有 86 字符的长输入才暴露(double 精度耗尽 → string.char 抛 invalid value)。
  这条判据就是为"能跑、能返回、产物错"那类缺陷写的。
- `TestEveryPagePluginDeclaresSVGIcon` / `TestIconUsesCurrentColor`:
  按**页面插件清单**遍历而不是只覆盖 billing。billing 当年空串的判据只管
  它自己,于是 channels 空串、quota 缺字段时全绿 —— 同类缺陷换文件复发
  没有任何东西会响。这条测试还当场咬出了 billing 自己缺 currentColor。

变异验证:zcode base_url 回退 ✓ / minimax adapter 回退 ✓ / 删 refresh_fn
绑定 ✓ / 去掉 buf 掩码 ✓ / OID 多套一层 TLV ✓(5/5 全部咬住)

## 四、计费(诊断,未改价)

查清"计费不正确"的根因:`prices.models` 只配了 **2 条**
(deepseek-v4.1-flash + 它的 / 前缀形式),而实际在用 **193 个模型**
⇒ 112006 次请求里 **35672 次(31.8%)成本按 $0 计**。
其中 28203 次集中在 2026-10-05 一天(历史一次性事件);
当前每天 27~137 次,且**渠道模型全部未定价**
(Doubao-Seed-2.1-Pro / GLM-5.3-Flash / MiniMax-* / qwen3.6-plus / hy3)。

调研:参考插件从 **models.dev** 拉模型目录(含 cost.input/output)。
实测 models.dev 覆盖我们 139/193 模型(72%),但**不能取中位数** ——
同一模型不同 provider 价差可达数千倍(glm-5.2 从 0 到 1.82),
0 价多是订阅制/coding plan。按 host 匹配 provider 只覆盖 6/24 源。
⇒ 价格口径需要用户确认,本轮不动价表。

6 个新探针(仅测试/诊断用):__platform_def_probe(平台定义真相)、
__codearts_jwk_pem_probe(PEM 重建 + 真签名)、__b64_probe(解码字节)。
2026-10-19 13:43:18 +08:00
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
6eeb5832a3 fix(provider): 上游错误文案别再丢 —— 补网关/腾讯错误体形状 + 修误导性日志
## 发现路径:读生产日志(不是测试报红)
```
unhandled upstream error body (adapter "openai" lacks transform_error):
  status=404 body={"error_msg":"404 Route Not Found"}
unhandled upstream error body (adapter "buddy" lacks transform_error):
  status=400 body={"code":11101,"msg":"Non-stream chat request is currently not supported",...}
```

### ★ 缺陷 A:错误体形状漏了「网关」与「腾讯系」两类
`__prelude.openai_error()` 只认 `error.message` / `error`(裸串) / `{code,message}`
/ `detail` / HTML `<title>` / 裸文本 六种,**没有** `error_msg`(华为/APISIX 网关)
与 `msg`(腾讯系用 msg 而非 message)⇒ 返回 nil
⇒ 客户端只看到 `api error 404: unknown error`,**而 body 里明明有可用文案**。

修法照参考(llm-adapter.ts:524 `errorDetail()` 显式认 `error_code`/`error_msg`;
buddy-adapter.ts:576 认 `msg` + `displayMsg.{zh,en}`):
- 新增 `{error_msg}` / `{msg}` / `{error_code,+error_msg}` 三种形状;
- 新增 `displayMsg.{zh,en}` 本地化文案,**排在最前**(参考注释明说"本地化文案
  最可读";其早期实现只读 `error.*`/`message` ⇒ 全部落空 ⇒ 把 291 字符原始
  报文糊进错误提示,而服务端早已备好中文说明)。

覆盖 7 个用共享前导的适配器:buddy/deepseek/github/groq/kimicode/openai/sensenova。

### ★ 缺陷 B:内核那句日志会把人带偏
措辞是 `adapter %q lacks transform_error`,但 **openai.lua:139 与 buddy.lua:166
都明明定义了该钩子** —— 真实原因只是它**返回了 nil**(认不出那个形状)。
我照着这句话去适配器里找"有没有这个函数",查了几轮才发现函数在、是返回值空。
修法:内核直接查事实(`p.vm.HasFn(adapter, "transform_error")`),
两种原因**分开措辞**,不再猜。

## 判据
`internal/lua/adapter_prelude_error_shapes_test.go`:
- 12 个形状子用例(4 个新增 + 6 个回归 + 2 个 displayMsg),
  **装载仓库里真实的 openai.lua**(含共享前导),不是自造桩 ——
  第一版用空 VM 跑,12 个全 FAIL(`adapter openai not loaded`),
  那等于什么也没验。
- 配对判据 `...StillReportsUnknownShapes`:真正认不出的形状仍须 `ok=false`,
  防止"为了让上面通过就把『抽不出』和『没这个钩子』混为一谈"。
- 变异:删掉新增的 error_msg/msg 分支 ⇒ APISIX 用例报红。

## 备注
`anthropic/gemini/mistral/ollama/opencodezen/qwen38/trae` 有自己写的
`transform_error`,不走共享前导,**未获得**本次新增形状(各自协议的错误体
本就不同)。此处只修共享前导的 7 个。
2026-10-17 00:37:32 +08:00
a189c4f61c fix(channels): zcode 适配器真正继承 anthropic 基座(此前 9 个函数只有 2 个)
## 缺陷(潜伏,从未被触发)
`channels-zcode` 适配器的手法是把 `anthropic.lua` 源码读进来执行、只覆盖
`transform_request`(身份块注入),其余协议转换全部复用基座。

但那句读取写在**适配器内部**。实测:
- 适配器运行在**另一个沙箱 VM** —— 那里 `__llmsproxy_plugin_dir` 与
  `LLMSPROXY_PLUGIN_DIR` **都不存在**、**也没有文件系统访问**;
- ⇒ `loadAnthropic()` 必然返回 nil,**且静默降级**:适配器照常装载,
  但只有它自己定义的 **2** 个函数,缺
  `transform_response` / `transform_stream_chunk` / `serialize_*` /
  `deserialize_request` 共 **7** 个。

**后果**:内核 `Transform()` 对缺失函数**响亮报错**(不是静默透传)⇒
非流式在响应阶段 502,流式**逐帧**报错 ⇒ 该渠道**完全不可用**。

**为什么一直没暴露**:zcode 从来没有凭据(0 账号)⇒ 源未注册
⇒ 该路径从未被调用过。拿到账号的那一刻才会炸,且报错指向内核而非配置。

## 修法
基座源码改由**插件主体**读好并**内联**进适配器源码(插件 VM 有 io,
实测可读)。与 v2.0.35 buddy 适配器同一手法。

两处必须注意的坑:
1. **不能拼接后 gsub 替换**:gsub 的替换串里 `%` 有特殊含义(`%1` 是捕获
   引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
   改用 `find(ph, 1, true)` + `sub` 的纯字符串替换。
2. **占位符 token 不能在注释里出现字面量**(第一版就栽在这:gsub 先命中了
   解释它的那行注释,替换掉注释、代码里仍留着未定义全局)。
3. 顺带补上**插件主体缺失的 `plugin.adaptersDir()`** —— 原先这个函数只
   存在于 zcode 适配器源码**内部**,插件主体从来没有过。

## 判据
- `internal/core`(**真实路径**,会咬变异):
  - `TestZcodeAdapterInjectsOfficialIdentityBlocks`
  - `TestZcodeAdapterKeepsAnthropicProtocolConversion`(工具调用 / 图像块存活)
  - `TestZcodeAdapterFailsLoudlyWithoutAnthropicBase` —— 语义已随设计更新:
    基座不可得现在应在**安装期**失败(新增 `adapters_dir` 测试缝隙把
    "基座缺失"从运行期逐帧炸变成安装期一条明确错误)
- `internal/lua/adapter_zcode_base_test.go`:验证源码模板 + 合并逻辑,
  **并如实标注边界** —— 它在测试里自己做替换,所以**不能**证明安装处内联了
  非空基座(实测:把 `asrc` 换成空串它不报红;那份由上面的 core 判据覆盖)。

**变异验证**:把安装处的 `asrc` 换成空基座 ⇒ core 三条判据全部报红;
用重构前的适配器源码 ⇒ `TestZcodeAdapterInheritsAnthropicBase` 报红(缺 7 个)。
2026-10-17 00:01:00 +08:00
58073a6cbe refactor(channels): 纯常量收进 plugin.K,腾出顶层 local 余量(此前余量为 0)
## 问题(实测)
`channels.lua` 的主 chunk **顶到 Lua 上限 200 个 local** ——
往文件顶部加**哪怕 1 个**顶层 local 就报:
  `main function has more than 200 local variables`
实测(LuaJIT 与 luac 5.4 一致,**响亮失败**,不是静默降级)。

后果不是"某个功能出错",而是**整个渠道插件编译失败 ⇒ 全渠道不可用**。
换句话说:这个文件此前**一个顶层 local 都加不了**,任何后续改动都会立刻炸。

## 修法
把 20 个**纯常量**(字符串/数字/自闭合字面量)从裸 `local` 收进 `plugin.K` 表 ——
**挂表字段不占 local 槽位**。只搬常量,**函数一律保留 local**:
函数之间存在真实的调用顺序依赖(前向引用),搬迁风险与收益不成比例。

搬迁是纯机械的,且脚本带**长字符串保护**:替换时逐行跳过
`[===[ ... ]===]` 区间,绝不改动适配器源码文本
(本文件里那 7 段长字符串是适配器代码,误改会直接弄坏适配器)。

### 实测结果
| 指标 | 前 | 后 |
|---|---|---|
| 加 1 个顶层 local | ✗ 编译失败 | ✓ 通过 |
| 加 10 / 20 个 | ✗ | ✓ 通过 |
| 字节数 | 589195 | 590042 |

## 判据(2 条 + 变异 2/2)
- `TestChannelsLuaHasLocalHeadroom`:注入 1 与 10 个顶层 local 后**仍能编译**。
  为什么用 **luajit** 而不是 `luac`:内核链接 `libluajit-5.1.so.2`,
  而 luac(Lua 5.4)对同一文件给出**不同结论**,用错工具会得到假绿。
  为什么不"数一遍 local":主 chunk 的 local 数受作用域嵌套影响,
  静态计数不可靠(同一文件被不同脚本数出 228/232/248 三个数);
  而"能不能再加"是**决定性**的,也正是我们需要的性质。
- `TestChannelsConstantsLiveInPluginK`(配对):抽样断言这些常量确实在
  `plugin.K` 内、且**没有**变回裸 local —— 否则余量会再次归零。
- 变异:用重构前的文件 → 报红;把常量改回裸 local → 报红。

## ★ 顺带更正两处我先前的错误说法(本轮实测推翻)
1. **不是静默失败**。我先前说"超出的 local 静默退化为全局" —— 错。
   LuaJIT 与 luac 都在第 201 个报**编译错误**。危险度远低于我所说。
2. **`adaptersDir` 为 nil 的真实原因不是"local 被顶掉"** ——
   它定义在 **`ZCODE_ADAPTER_SRC` 长字符串内部**(适配器源码文本),
   在**适配器沙箱**里执行,而那里没有 `__llmsproxy_plugin_dir`、没有文件系统。
   所以 zcode 的 `loadAnthropic` 必然返回 nil、BASE=nil
   (它自写了全部转换所以功能没问题,但"复用 anthropic、协议不漂移"的
   设计意图从未生效)。
   ⇒ **教训**:读 10000+ 行的 Lua 文件时,先扫出所有长字符串区间;
   落在里面的"函数定义"只是文本,不属于插件作用域。
2026-10-16 21:49:12 +08:00
a5923b3797 fix(buddy): 新增内置 buddy 适配器(glm-5.1 的 reasoning_effort + 流式 Accept)
## 真实缺陷 ①:glm-5.1 恒 400(用户报"这个模型不可用")
上游回 `400 {"code":11150,"msg":"the reasoning effort value is not supported
by the current model"}`。探针逐字对照实验(body 完全由探针给定):
  glm-5.1 裸(无该字段)             → 400 11150   ✗
  glm-5.1 + reasoning_effort:medium  → 200         ✓
  glm-5.1 空串                       → 400 11150   ✗
**任何非空档位都被接受**(low/medium/high/xhigh/max/minimal 逐个 200);
而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)⇒ 这是 glm-5.1 **独有**要求。
参考 product.ts:237 声明它只有 `['medium']` 一档。

## 真实缺陷 ②:流式请求缺 Accept: text/event-stream → 空回复
不发该头时上游**接受请求(200)但只回一帧空内容**(completion_tokens=0、
finish_reason 直接 stop),既不报错也不产出正文 —— 表现是"模型不回答",
极易误判成模型坏了或额度不足。同 body 加该头 → 39~43 帧正常 SSE。
参考 buddy-adapter.ts 的 send() 固定发它。

## 实现:内置 `buddy.lua`(不是插件自装)
**为什么不用插件自装适配器**(channels-zcode/qoder/cline 那条路):
`channels.lua` 的**主 chunk 已 248 个 local**,Lua/LuaJIT 上限是 200 ——
超出的 local 会**静默退化为全局**(`luac -p` 通过,运行时才 `attempt to call
global 'x' (a nil value)`)。实测证据:`adaptersDir` 是第 208 个 ⇒ 调用点拿到 nil
⇒ `loadAnthropic` 静默返回 nil ⇒ **zcode 适配器从来没有基座**
("复用 anthropic"的意图从未生效,只是它自写了全部转换所以能用)。
我第一版就是这么写的,结果适配器装不上(`adapter channels-buddy not loaded`),
且 `hy3`/`glm-5.3`/`kimi-k3-1` 全被打成空回复 —— 只有 `glm-5.2` 因多源兜底
"看起来正常",症状再次指向错误方向。

⇒ 改走内置适配器(与 trae/sensenova/opencodezen 同性质,`deploy.sh` 自动同步),
`channels.lua` 只改**一行** `adapter = "buddy"`,不再触碰那个已贴上限的文件。

## ★ 排查中发现的沙箱事实(值得记)
**适配器运行在另一个沙箱 VM**:那里 `__llmsproxy_plugin_dir` 与
`LLMSPROXY_PLUGIN_DIR` **都不存在**(实测均为 nil),**也没有文件系统访问**。
故任何"在适配器里读兄弟适配器源码"的写法都拿不到基座且静默降级。
判据 `TestBuddyAdapterDefinesAllTransforms` 咬住这条。

## 顺带修:hy4-preview-x 从目录剔除
实测它对上游**恒回 11102**(`model service info not found`,加/不加
reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。它被服务端 agents
引用,于是 `agentRef` 例外把它放回目录 ⇒ 用户选中必 400 而错误指向模型。
参考用**白名单重建**达到同样效果(其兜底表里没有它)。本仓加等价的精确黑名单
(只放实测确认的,不做猜测),并在两处兜底目录里一并剔除。

## 验证
buddy **15/15 模型全部可用**(含 glm-5.1),逐一实测无失败;
`hy4-preview-x` 已从源目录消失(16 → 15)。

## 判据(3 条 + 变异 2/2)
- `TestBuddyAdapterEffortAndHeaders`:glm-5.1 补 medium / hy3 不补 /
  调用方给的档位被尊重 / Accept 必须是 SSE + 身份头齐备
- `TestBuddyAdapterKeepsOpenAIWire`:协议转换与 openai 一致(只是补两个字段)
- `TestBuddyAdapterDefinesAllTransforms`:**四个转换函数都存在** ——
  咬"静默降级"而非"某模型能回话"(后者会被多源兜底掩盖,本会话已栽两次)
- 变异:删 stream_chunk / 不补 effort → 均报红

## 一起提交的其它独立缺陷(均在本次调查中发现)
- buddy 目录过滤(四类判据)与 hy4-preview-x 剔除
2026-10-16 20:47:14 +08:00
91f6d1f32f fix(channels): 日志截断按字符(中文被切出 U+FFFD)+ 面板排版
## 真实缺陷(v2.0.33 上线后在截图里看到)
日志尾显示成:`能力增强不�`
根因:`tostring(cerr):sub(1, 120)` —— **Lua 的 sub 按字节切**,而中文占 3 字节
⇒ 切在字符中间产生**替换字符 U+FFFD**。一句本来完整的诊断("…能力增强不可用,
已退回兜底表")看起来像**数据损坏**,实际只是截断方式错。

生产实测复现:该条 120 字节截断后,末尾正好落在"不"字的第 1 字节上,
python 侧解码得到 `…能力增强不\ufffd`。**修法**:`plugin.utf8Clip(s, maxBytes)`
先按字节切、再回退到最后一个完整 UTF-8 序列边界(最多退 2 字节)。
三处中文文案截断全部改用它。

## 判据(1 条静态 + 1 条行为 + 变异 1/1)
- `TestLogTruncationIsUTF8Safe`(静态):助手存在 / 三处中文截断不再用裸 sub /
  未超长时原样返回 / 回退逻辑处理孤立首字节
- `TestUtf8ClipBehaviour`(**行为**,经 `plugin.__utf8_clip_probe` 走真实函数):
  对 6 个长度断言 `utf8.ValidString(out)` 且不超字节上限。静态断言看不出
  "结果是否真的合法 UTF-8",所以必须有这一条 —— 实测 n=120 从
  `…能力增强不�`(非法)变为 `…能力增强不`(合法)
- 变异:把 utf8Clip 换回裸 sub → 报红

## 排版抛光(截图里可见的三处)
- 头部控件原用 `<h2>` + flex,checkbox 被挤到换行且文字竖排 ⇒ 改 `<b>` +
  独立 flex 行 + `white-space:nowrap`
- 长诊断在窄卡片里被**截断**(而截断的尾巴正是最该看的部分)⇒ `word-break`
- 列表加 `line-height`,可读性

## 兼容性
历史 state 里的 41 条老条目没有 `level` 字段 —— 前端 `e.level || "info"`
已容忍,**不需要数据迁移**(判据亦未假设字段必然存在)。
2026-10-16 17:44:04 +08:00
c1048dcd4e fix(channels): 运行日志加级别/归属/折叠,并从渠道详情移出(C+A)
## 问题(生产实测,非猜测)
用户问「为什么 WebUI 默认显示插件全部日志」。用 CDP 打开真实 UI + 读
`/channels/status` 实测,发现是**三个问题叠加**,而表层症状是"看到的全是噪音":

### ① 归属错误:全局日志渲染在「单个渠道详情」里
`plugin.state.log` 是**单一全局数组**(`logEvent` 写入的条目只有 `{at, msg}`,
**连 platform 字段都没有**),但这个 `<ul id="ch-log">` 的 DOM 位于
`#ch-detail`(点开某渠道才显示)⇒ 用户点开 buddy,看到的却是
trae/minimax/qoder 的日志混在一起,**无从区分**。

### ② 50 条里 48 条(96%)是周期任务噪音
实测分类:`已同步源…` 11×、`模型刷新失败…` 9×、`模型目录已刷新` 9×、
`能力元数据未刷新` 9×、`自动领取…` 9× —— **唯一模式只有 8 种**。
原因:`refresh_models`(2h) + `auto_claim`(2h) + `refresh_creds`(0.5h) 三个
`first_due_now` 任务每次跑写 3~5 条 ⇒ **约 2 小时就把 50 条环形缓冲冲满**,
于是真正的用户动作(如「账号删除」)被挤到看不见。

### ③ 无级别、无去重、无折叠 → 噪音与错误同权重并排

## 修法(用户选 C+A)
**C —— 修正归属**:把「运行日志」从 `#ch-detail` 移出,成为**常驻的独立卡片**
(`#ch-log-card`)。判据断言它与渠道详情是**兄弟节点**(div depth=0),
不是父子 —— 否则"点 buddy 看到 trae"会以另一种形式回来。

**A —— 加级别与过滤**:
- `logEvent(msg, level, platform)`:level ∈ info|task|warn|error。
  定时任务的**常规**结果记 `task`(前端默认折叠),只有异常升为 warn/error。
- **紧邻同文折叠**:与 `log[1]` 完全同文时不新增条目,只 `n=n+1` 并刷新时间。
  ⚠️ 只对**紧邻**条目折叠 —— 否则一条每小时出现的消息会不断"跳"到最前,
  时间线失去意义。前端显示成 `×N`,既保留"重复了多少次"又不刷屏。
- 三个视图开关:级别门槛(全部/警告及以上/仅错误)、显示例行任务、
  按当前选中渠道过滤(不带 platform 的跨平台汇总只在「全部渠道」出现)。
- `refresh_models` 的失败改为**逐平台各记一条**(原先 N 个挤成一条汇总,
  既不属于 trae 也不属于 minimax ⇒ 点进任何单渠道都看不到自己的问题)。

## 判据(3 条 + 变异 2/2)
- `TestChannelsLogEventCarriesLevelAndPlatform`:签名三参 / 条目带两字段 /
  紧邻折叠 / **折叠对象必须是 log[1]**
- `TestChannelsPeriodicTaskLogsAreDowngraded`:逐条咬住**具体调用点**被降级
  (而不是"存在 task 这个词"——那样一处就为真,测不出漏降级的噪音源)
- `TestChannelsLogPanelHasFilters`:日志卡片与渠道详情是**兄弟**(div depth=0)
- 变异:去掉折叠 / 把卡片放回详情内 → 均报红

## 顺带修好的环境事故(重要)
排查中全量测试突然大批失败(`loopback 未就绪(未取得端口)`)。我一度以为是
自己的改动,**`git stash` 后在干净版本上复现 → 预先存在**,继续追到根因:

**`/tmp/bisect.py`(另一个 agent 的临时脚本)遮蔽了 Python 标准库的 `bisect`**
—— 而 `tempfile` → `random` → `bisect`,于是**任何 `import tempfile` 都炸**,
回环接收器(python3)起不来。`sys.path` 含 `/tmp` 就是这个后果。
已移走(`/tmp/_shadowed-stdlib-conflicts/`,不删除以免打断那个 agent),
Python 与全部测试立刻恢复。

⇒ **教训**:临时脚本**绝不能**在 `/tmp` 下用 stdlib 模块名命名;
它会让全机 Python 静默失效,而症状("回环未就绪")与原因毫无关联。
2026-10-16 17:19:46 +08:00
2b8fcaf434 fix(opencodezen): 必须回传 reasoning_content(thinking 模式硬要求)
## 真实缺陷(我自己的会话被它打断过)
生产实测(2026-10-16):
  provider localzen: api error 400: Upstream request failed: [invalid_request_error]
  The `reasoning_content` in the thinking mode must be passed back to the API.
审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。

根因:`transform_request` 里写着 `msg.reasoning_content = nil`,注释理由是
"Zen 免费池不接受回传(Go 侧相反)"。**该假设方向是反的** —— 上游要的就是它。

## ★ 为什么这个缺陷极难归因(值得记)
失败是**交替**出现的:只有当上一轮**真的产出了 reasoning**(thinking 模型 +
reasoning_effort 开启 + 长上下文)时,回放那段历史才会被拒;普通回合照常 200。
于是"同一个模型同一会话,成功与失败交替"——看起来像**上游抖动或限流**,
而客户端看到的 502 与"上一轮想过"这件事**毫无关联**。

我一开始也走了弯路:用简短的 curl 复现不出来(8/8 成功),因为那些请求的
历史里没有"模型自己产出的 reasoning"。**必须让上一轮真的产生思考**才复现。

参考实现的方向一致:`openai-compat.ts` 的 `serializeMessages`(opencode 适配器
直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
    `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
即「有就回传」,无额外条件。

## 修法
保留 `reasoning_content`(只把**空串**丢掉 —— 多余字段部分上游会挑)。
不改成"仅当 thinking 开启才回传":我们**无法**从请求可靠判定上游是否处于
thinking 模式(真正知道的是上游)。而"有就回传"是零风险方向 ——
上游不需要时忽略即可(实测传与不传同为 200),需要时缺了就是硬 400。

## ★ 顺带发现的第二处问题
提交 `6113924` 的说明声称
"Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split" ——
**该测试在全仓不存在**(`git log -S` 全历史搜索为空)。也就是说这条行为
**从未被任何判据固定**,所以它反了也没人知道。本轮补上真正的判据。

## 判据(2 条 + 变异 1/1)
- `TestOpenCodeZenKeepsAssistantReasoningContent`:assistant 回合带 reasoning 时
  出站体必须有它。**咬住"被剥掉"这个具体行为**,而不是"请求成功"——
  后者在非 thinking 回合下永远为真,测不出本缺陷。
- `TestOpenCodeZenDropsEmptyReasoningContent`:空串必须丢掉(配对判据,
  防"改成无条件保留"这类过度修正)。
变异:恢复 `msg.reasoning_content = nil` → 报红。

## 说明
`opencodezen.lua` 在仓库内,而 `deploy.sh` 的 `sync_adapters` 只覆盖
**与仓库同名**的适配器文件 ⇒ 本修复会自动生效(生产另有 opencodego/opencode/
agentrouter 三个仅存在于线上的文件,不受影响)。
2026-10-16 14:52:13 +08:00
47fe759025 fix(channels): buddy 目录过滤不可调用模型(照参考 isChatModel 四类判据)
## 真实缺陷
`/v3/config` 的 `data.models` 混着**不是对话模型**的条目,我们原样全收 ⇒
生产源里 24 个模型,其中 `nes-gf` / `codewise-completions` / `codewise-rewrite` /
`codewise-jump` / `codewise-default-model-v2` / `hunyuan-image-alpha` / `auto`
全部调不通(`glm-5.1` 也 404)。用户选中报错,而错误文案指向**模型**
⇒ 极易被误判成"这个模型坏了"。

参考实现(buddy.ts `isChatModel` / `isAutoSelectAlias`)逐条给出四类判据,
且都有实测依据;本轮逐条移植:
  ① 自动选择别名 `auto` / `default`
     ⚠️ **只按这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤
     `default-model` / `fast-model`(国际版抽象别名,但被 craft agent 引用,
     是官方推荐入口)。参考注释专门警告过这点。
  ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,或 `supportsExtra`
     (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
  ③ `maxOutputTokens > 0 且 ≤ 256`(补全用途;对话模型普遍 ≥24000)
  ④ 带 `text-to-image` 标签(如 hunyuan-image-alpha)

⇒ 故 `models` 先建成 id→meta 映射(③④ 需要元数据),再逐条判定。
同时收 `data.agents[].models` 里**被 agent 引用**的清单并合并 —— 参考据此给
`agentReferenced` 打标(`hy4-preview-f` 只由本端点下发且被 agent 引用,
白名单式重建会把它丢掉)。

## 判据(1 条 + 变异 1/1)
`TestBuddyCatalogFilterDropsNonChatModels`:断言四类规则**都在**源码里
("有人精简掉一条"就会把不可调用模型放回选择器),并**反向**断言
`default` 没被写成前缀匹配(那会误伤 `default-model`)。
变异:删掉 `nes-` 规则 → 报红。

## 说明
这是上一提交(v2.0.30)同一轮的第二个发现:v2.0.30 修掉 URL 拼接后,
buddy 通道**才真正可达**,于是"可达之后目录里哪些真能调"这件事第一次暴露出来。
两个缺陷是串联的 —— 不先修 URL,这个目录问题无法被观察到。
2026-10-16 14:06:51 +08:00
d795266572 fix(channels): 8 个平台 base_url 侵占 endpoint 路径 ⇒ 上游 404 Route Not Found
## 真实缺陷(用户可见)
内核的上游 URL 是 `base_url + endpoint`(provider.go ChatURL),而适配器默认
`endpoint = "/chat/completions"`。多个渠道平台把**完整路径**写进了 `base_url`
⇒ 实际请求变成
  https://copilot.tencent.com/v2/chat/completions/chat/completions
⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,**不是**模型错误)。

受影响 8 个平台:buddy / cline / lobsterai / opencode / qoder / loomy / raccoon
(zcode 当时已用 `endpoint` 覆盖,形态正确)。
另有 loomy 的**第二处**错误:host 用了 `loomy.xunfei.cn`(Web 前端/OAuth 回调域),
实测该 host 的 /chat/completions 返回 **HTML 网页**;正确推理域是参考的
`loomyad.xunfei.cn/api/v1`(返回正常 JSON)。

## ★ 为什么这个缺陷活了很久(最重要的教训)
**模型名被多个源服务**。`deepseek-v4.1-flash` 同时由 4 个源提供
(alittokenplan/vela/oin/channels-buddy)⇒ buddy 源失败后网关**继续尝试下一个源**
并成功 ⇒ 用户看到"能回话" ⇒ "buddy 通道是好的"这个结论**完全没有证据**。
本轮我一度正是这么误判的。而 `hy3` **只由 buddy 一个源**提供 ⇒ 一调就暴露真因。

⇒ **判据必须断言源的结构不变量(URL 形态),不能靠"某个模型能调通"** ——
后者会被多源冗余掩盖。已按此写判据。

## 排查手法(可复用)
怀疑"某模型不可用"时,先列**该模型由几个源服务**:
  1 个 ⇒ 该模型是那条源的健康探针(用它测)
  >1 个 ⇒ 它调通**不能**证明任何单一源可用(换独有模型再测)
另:非流式请求会被 buddy 直接 400 拒绝
(`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`),
我第一次用 stream=false 时四个模型全 400,**看不出**模型差异,差点把结论下成
"头组合无区别"就收工。

## 修法
`base_url` 只留 host(或协议公共前缀),路径移入该平台的 `endpoint`。
逐平台按参考的 `apiBase` + `chatPath` 取值核对,并**实测**修正后的 URL:
buddy/cline/lobsterai/qoder/raccoon → 401(缺凭据,正确)
loomy → 200 `{"code":"100002","desc":"缺少 token"}`(正确)
opencode → 500(路由存在;`https://opencode.ai/v1/...` 是 404+HTML,已证伪)
测试注入平台也一并对齐(免被当范例复制)。

## 判据(2 条 + 变异 2/2)
- `TestChannelSourceBaseURLHasNoChatPath`:静态扫描全部 `base_url`,不得含
  `/chat/completions` `/v1/messages` `/generateContent` `/api/chat`。
- `TestChannelBaseURLJudgeBitesOnBadInput`:用**故意坏数据**验证判据的判别力
  (防判据自己写歪成恒真)。
变异:把 buddy 恢复成旧形态 → 报红。

## 顺带修正
`TestUpstreamGapsAreDocumented` 原先钉死 qoder 的**整串 URL 字面量** ⇒
这次纯形态重构(语义未变)把它弄红了 —— **判据在咬自己**。改为断言
`base_url` 与 `endpoint` **两半**,语义不变但对形态重构免疫。
2026-10-16 13:51:36 +08:00
a82ae2b60e fix(channels): 撤源失败的诊断必须带真正原因(不再只说"无返回")
## 我上一版(v2.0.28)自己引入的缺陷
内核桥的签名是 `call(name, args) -> (result, nil) | (nil, err)` ——
**失败时第一个返回值是 nil,原因在第二个返回值里**。
我写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒为 nil ⇒ 日志只说
「撤源(重建)失败 channels-buddy-buddy-main: 无返回」。

**一句不可操作的诊断比没有更糟**:它把"权限没授予 / 超时 / 拒删最后一个源"
三种完全不同的原因(对应三种完全不同的处置)压成同一句空话。生产实测已复现
这条日志(部署时全量重同步,权限尚未授予)。

## 修法
两个撤源点都改成接**两个返回值**,日志带上内核给出的原文原因:
  `kernel action "remove_source" is not granted to plugin "channels";
   enable it in the plugin's kernel permissions`
并把"要授予哪个动作、声明在 plugin.kernel"一并写进提示。

## 判据收紧(旧判据咬不住这个变异)
原来只断言日志含"撤源失败"和"remove_source" —— **只接第一个返回值的版本照样通过**。
新增断言:日志必须含内核给的**真实原因**(`not granted`)。
这是本会话第 9 类判据教训:**判据只看"失败了",不看"为什么失败"**,
于是它无法区分"诊断可用"与"诊断是空话"。
2026-10-16 10:26:55 +08:00
2d2c27c430 fix(channels): remove_source 漏声明(删了账号源还在对外服务)
## 真实缺陷(用户可见)
`plugin.kernel` 只声明了 5 个内核动作,而插件在 **5 处**调用 `remove_source`
—— 声明与使用**漂移**。生产按 declared 授予 ⇒ **撤源全部被拒**。

更坏的是它**静默**:`Core.AddSource` 是**同名 upsert**,所以 `syncAccountSource`
里"先 remove 再 add"的 remove 失败**看不出来**(add 会覆盖同名源)——
只有**纯删除路径**才暴露:
  • `dropAccountSource`(账号停用/删除)→ **账号删了,它的源还在对外服务**
  • `account_delete` 收尾清旧单账号源
  • 两处 `channels-<plat>` 旧源清理

我实测确认:只授 add_source 时删账号,其源**仍在**内核源列表里。

## 修法
① 声明补上 `remove_source`(并加注释说明"加动作时同时改这里")。
② `dropAccountSource` / `syncAccountSource` 的撤源**不再忽略返回值**,
   失败**记进事件日志**(面板可见,附可操作提示)——
   "一个被丢弃的返回值"不该是权限缺失的唯一痕迹。
③ 判据 `TestChannelsDeclaresEveryKernelActionItCalls`:**静态扫描**源码里所有
   `__llmsproxy_kernel.call("X")`,要求每个 X 都在 `plugin.kernel` 里声明。
   将来任何人加了内核调用却忘了声明,会在这里报红,而不是等线上静默失效。

## 判据(3 条 + 变异 3/3)
- 授予 remove_source 时:删账号**确实撤掉源**
- 未授予时:**必须可见地失败**(事件日志含"撤源失败")
- 声明必须覆盖全部实际调用
变异:忽略返回值 → 报红;漏声明 remove_source → 报红。

## 部署后需补的一次操作
生产权限集当前只有 add_source ⇒ 需授予 remove_source(否则既有账号的
"停用/删除"仍撤不掉源)。已在部署后手工授予,并复核 declared == granted。
2026-10-16 10:04:13 +08:00
0f83fc048a fix(channels): 账号顺序的确定性 + reorder 真正改内核源顺序
## ① 真实缺陷:同秒创建的账号,顺序**不确定**
`poolList` 的排序键是 `priority` 降序 + `createdAt` **升序**,而 `createdAt` 是
**秒级**(`os.time()`)⇒ 同一秒建的两个账号比较**相等** ⇒ 相等时 `table.sort`
的结果取决于 `pairs()` 遍历顺序 —— **不确定**。
实测:按 a,b,c 顺序建三个账号,`poolList` 回的是 `[a,c,b]`。

后果不只是"顺序看着乱":**源注册顺序 = poolList 顺序 = 内核尝试顺序**
⇒ 同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下影响先用谁的积分),
且可能每次进程重启都变。修法:加 **id 作稳定兜底键**(唯一且不变)。

## ② reorder 只改字段、不影响路由
`account_reorder` 只设 `acct.priority`,**不重注册源**。而一个源一旦注册,
顺序就固定了 ⇒ 重排对实际路由**毫无影响**("优先账号"并不会先被试)。
修法:重排后按新顺序**整体重放一遍** `syncAccountSource`
(remove+add 会把源移到最后,故必须整体重放,只补被移动的那几个相对顺序仍不对)。

## 判据(2 条 + 变异 2/2)
internal/core/channels_reorder_effect_test.go
- reorder 必须真的改变**内核 list_sources 的顺序**(判据看内核里的真实顺序,
  不是 pool 里的 priority 字段)
- 同优先级 + 同秒创建时,顺序必须由**稳定键**(id)决定(字典序)

★ 两个判据第一版**都不咬变异**,原因各异,值得记下:
1. reorder 判据:初始顺序恰好**已是**目标顺序 ⇒ 无法区分"重排生效"与"本来就对"。
   改法:显式断言初始顺序、并让目标顺序与之**相反**。
2. 确定性判据:我直接断言 `account_add` 之后的顺序,而 `account_add` 是
   **直接同步自己那一个账号**(源顺序 = add 顺序),**走不到排序逻辑** ⇒
   测的其实是 add 顺序。另外**多轮 in-process 反复构造也测不出** ——
   `pairs()` 顺序在同一次进程里是稳定的,只有跨进程/跨分配才变。
   改法:走**批量重注册**路径(`__resync_sources_probe`)+ 显式把 priority 拉平
   构造"排序键完全相同"的场景,再断言字典序。

## 顺带修的判据自身问题
- `sourceOrder` 原来顺带调了 `__sync_source_probe`(内部 remove+add)⇒
  **"测之前先动了被测对象"**,顺序忽而两个忽而一个。改为**只读**。
- 探针取内核返回时按 `{sources:[...]}` 取,而 `actionListSources` 返回的是
  **裸数组** ⇒ 恒得空列表。
- 夹具漏授 `add_source` 内核权限 ⇒ 源一个都不出现(内核会明确拒绝,只是我
  没看返回值)。子测试才能拿到独立的 env 与目录(同函数内 `t.Setenv` 只生效一次)。
2026-10-16 09:36:02 +08:00
63 changed files with 5861 additions and 487 deletions

View File

@ -0,0 +1,86 @@
# 渠道问题清单(2026-10-19 晚生产实测)
> 逐项修复。每项标注:现象 / 根因 / 状态。
## P1 trae 非流式 500(unmarshal unified response: invalid character 'e')
- **现象**:`stream:false` 打 `channels-trae:*` 必现 500,body 以 `event:metadata data:{...}` 开头。
- **根因**:trae SOLO 上游**对非流式请求也回 SSE**(且帧带 `event:` 前缀)。
插件自装的 `channels-trae` 适配器 `transform_response` 直接 `__prelude.openai_response(raw_body)`,
不识别 SSE;而内核只把 `data:` 行交给流式适配器,非流式路径把**整个 body**(含 `event:` 行)
塞给 transform_response → json.decode 失败 → 原样返回 → Go 侧 unmarshal 报 `invalid character 'e'`。
(`internal/lua/adapters/trae.lua` 有 SSE 聚合逻辑,但 channels 平台用的是**插件内嵌**
`TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。)
- **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析,
聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。
- **状态**:⬜
## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported)
- **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。
- **根因**:腾讯 codebuddy 网关**只支持流式**。适配器 `transform_request` 没有强制 `stream=true`,
非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。
- **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加
SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。
- **状态**:⬜
## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝
- **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1
被占满)与上游 `11133: the request parameters were rejected`。
- **根因**:① max_concurrent=1 是**刻意的**(channels.lua:4332 注释:并发配额按模型计),busy
是正常自保;② 11133 与空 tool_calls 修复(未提交的 buddy.lua 改动)相关,待部署后观察。
- **状态**:观察项,非缺陷。
## P4 Qoder 领取失败(缺少凭据对象)——已修复
- **现象**:`channels/claim` 报 `{"error":"缺少凭据对象"}`,而 account_test 正常。
- **根因**:qoder 是 PKCE 设备码流且无 register 第二跳,落库凭据是**裸 token 字符串**;
`qoderClaim` 硬要求 `type(cred)=="table"`。
- **修法**:`qoderClaim` 兼容裸 token(非空时包 `{access_token=tok}`);
回归测试 `TestQoderClaimAcceptsBareTokenCredential`。
- **状态**:✅ 已修复并部署生产(claim 现在返回真实上游业务结论)。
## P5 Qoder/OpenCode 渠道页账号串台(UI 渲染竞态)
- **现象**:从 qoder 切到 opencode 详情页,qoder 的账号行闪现在 opencode 卡片下。
- **根因**:`renderDetail()` 切渠道时不清空 `#ch-accts`,`renderAccounts` 是异步的,
空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。
- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels
加代际校验(`state.selected !== id` 时丢弃响应)。
- **状态**:✅ 已改(channels.lua),待部署。
## P6 CodeArts「完全不可用」——多数模型实际可用
- **现象**:用户报"codearts 完全不可用"。实测逐模型探测:
- 可用:openpangu-2.0-flash/pro、GLM-5.2、glm-5.3、kimi-k2.6-vl、Qwen3-VL-235B、glm-5.2-sft-harmony
- 429(限流非下架):deepseek-v4.1-flash、deepseek-v4-flash、openpangu-2.0-pro(间歇)
- 404(真下架,需标 disabled):GLM-5.3 报 `InferHub.002002009.404: The model is not registered`
- **附带发现**:`GLM-5.3`(大写变体)不在 deadModels 记录里——`recordDeadModel` 只被
`__record_dead_probe` 调用,**request_end hook 没接 isModelGone**(583976f 引入的
基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。
- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。
建议把「凭据到期自动续期」纳入定时任务检查。
- **状态**:⬜(待补 dead-model 挂接 + GLM-5.3 标 disabled)
## P7 CodeBuddy 海外账号 / Qoder 国内账号
- **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际
(openapi.qoder.sh / qoder.com)。用户要求两边都能选区。
- **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。
- **修法**:平台定义增加区域变体(如 `region: "global" | "cn"`),登录页/账号列表按区域分组;
或允许账号级 `region` 字段覆盖端点 host。
- **状态**:⬜(需求确认后实施)
## P8 商汤(Raccoon)扫码登录「链接无效」
- **现象**:用户点登录后看到的二维码/链接打不开。
- **排查**:登录会话本身正常发起(二维码已生成、轮询端点存在)。
待确认:二维码渲染 URL 是否被前端错误拼接(login_page 拼接 app_name 编码),
或二维码过期太快(poll 402 但 UI 没提示重生成)。
- **状态**:⬜(需要用户复现一次,抓前端渲染的 URL)
## P9 死模型记录基础设施未挂接(P6 的根因之一)
- `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的
`on_request_end` 只处理 term_error(账号级),不记模型级死亡。
- **修法**:on_request_end 失败分支里,取 payload.model + error message,
`isModelGone(msg)` 为真时 `recordDeadModel(provider, model, msg)` 并重同步源。
- **状态**:⬜
## 部署注意事项
- 工作树有未提交改动:buddy.lua / trae.lua(tool_calls 空 {} 过滤)、provider.go(stop→tool_calls 归一)、
channels.lua(qoder 裸 token + UI 竞态)、provider_test.go、channels_qoder_claim_test.go。
- 生产 channels.lua 哈希与仓库不同步是常态(deploy.sh 会 sync_plugins)。
- 商汤账号:用户需在二维码有效期内扫码确认;当前账号 5a2ea34df521 无凭据属正常(未完成扫码)。

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

@ -62,13 +62,18 @@ const (
DefaultSourceConcurrency = 8
)
// Model is a single exposed model id bound to a source, with priority used by
// AUTO auto selection (higher number = preferred).
// Model is a single exposed model id bound to a source.
//
// ★ 曾经有 `Priority int` 字段,并号称"高者优先参与 AUTO 选源"。**那是错的**:
//
// AUTO 链由 `config.ModelScope.Tier` 驱动(见 scheduler.Chain),而 priority
// 只被 `bestChatModel/bestImageModel` 用于"本源码内部该发哪个模型",
// 与跨源调度毫无关系。把它当成调度依据会让人误以为改个数字就能改顺序,
// 改完发现毫无效果 —— 已彻底删除,避免再次误导。
type Model struct {
ID string `yaml:"id" json:"id"`
Priority int `yaml:"priority" json:"priority"`
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
ID string `yaml:"id" json:"id"`
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
}
// Source describes a single upstream LLM provider.

View File

@ -52,9 +52,6 @@ sources:
if cfg.Sources[1].MaxConcurrent == 0 {
t.Fatal("default max_concurrent not applied")
}
if cfg.Sources[0].Models[0].Priority != 100 {
t.Fatalf("priority = %d", cfg.Sources[0].Models[0].Priority)
}
if cfg.DefaultModel != "AUTO" {
t.Fatalf("default model = %q", cfg.DefaultModel)
}

View File

@ -14,9 +14,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "deepseek",
MaxConcurrent: 8,
Models: []Model{
{ID: "deepseek-v4-flash", Priority: 100, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 90, Kind: "chat"},
{ID: "deepseek-v4-flash-vision-exp", Priority: 60, Kind: "chat"},
{ID: "deepseek-v4-flash", Kind: "chat"},
{ID: "deepseek-v4-pro", Kind: "chat"},
{ID: "deepseek-v4-flash-vision-exp", Kind: "chat"},
},
},
{
@ -25,10 +25,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "glm-5.3", Priority: 100, Kind: "chat"},
{ID: "glm-5.3-flash", Priority: 90, Kind: "chat"},
{ID: "glm-5.2", Priority: 80, Kind: "chat"},
{ID: "glm-5.1", Priority: 60, Kind: "chat"},
{ID: "glm-5.3", Kind: "chat"},
{ID: "glm-5.3-flash", Kind: "chat"},
{ID: "glm-5.2", Kind: "chat"},
{ID: "glm-5.1", Kind: "chat"},
},
},
{
@ -37,10 +37,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "kimi-k3", Priority: 100, Kind: "chat"},
{ID: "kimi-k2.7-code", Priority: 90, Kind: "chat"},
{ID: "kimi-k2.7-code-highspeed", Priority: 85, Kind: "chat"},
{ID: "kimi-k2.6", Priority: 70, Kind: "chat"},
{ID: "kimi-k3", Kind: "chat"},
{ID: "kimi-k2.7-code", Kind: "chat"},
{ID: "kimi-k2.7-code-highspeed", Kind: "chat"},
{ID: "kimi-k2.6", Kind: "chat"},
},
},
{
@ -49,11 +49,11 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "deepseek-ai/DeepSeek-V4-Pro", Priority: 100, Kind: "chat"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Priority: 90, Kind: "chat"},
{ID: "moonshotai/Kimi-K2.6", Priority: 80, Kind: "chat"},
{ID: "zai-org/GLM-5.1", Priority: 70, Kind: "chat"},
{ID: "Kwai-Kolors/Kolors", Priority: 50, Kind: "image"},
{ID: "deepseek-ai/DeepSeek-V4-Pro", Kind: "chat"},
{ID: "deepseek-ai/DeepSeek-V4-Flash", Kind: "chat"},
{ID: "moonshotai/Kimi-K2.6", Kind: "chat"},
{ID: "zai-org/GLM-5.1", Kind: "chat"},
{ID: "Kwai-Kolors/Kolors", Kind: "image"},
},
},
{
@ -62,9 +62,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 4,
Models: []Model{
{ID: "4.0Ultra", Priority: 100, Kind: "chat"},
{ID: "generalv3.5", Priority: 80, Kind: "chat"},
{ID: "max-32k", Priority: 70, Kind: "chat"},
{ID: "4.0Ultra", Kind: "chat"},
{ID: "generalv3.5", Kind: "chat"},
{ID: "max-32k", Kind: "chat"},
},
},
{
@ -73,11 +73,11 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "qwen3.8-max", Priority: 100, Kind: "chat"},
{ID: "qwen3.8-flash", Priority: 90, Kind: "chat"},
{ID: "qwen3.7-plus", Priority: 85, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
{ID: "kimi-k3", Priority: 70, Kind: "chat"},
{ID: "qwen3.8-max", Kind: "chat"},
{ID: "qwen3.8-flash", Kind: "chat"},
{ID: "qwen3.7-plus", Kind: "chat"},
{ID: "deepseek-v4-pro", Kind: "chat"},
{ID: "kimi-k3", Kind: "chat"},
},
},
{
@ -86,9 +86,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "ernie-5.0-turbo", Priority: 100, Kind: "chat"},
{ID: "ernie-5.0", Priority: 90, Kind: "chat"},
{ID: "deepseek-v4-pro", Priority: 70, Kind: "chat"},
{ID: "ernie-5.0-turbo", Kind: "chat"},
{ID: "ernie-5.0", Kind: "chat"},
{ID: "deepseek-v4-pro", Kind: "chat"},
},
},
{
@ -97,9 +97,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "doubao-seed-2.0-pro", Priority: 100, Kind: "chat"},
{ID: "doubao-seed-2.0", Priority: 90, Kind: "chat"},
{ID: "doubao-seed-2.0-flash", Priority: 80, Kind: "chat"},
{ID: "doubao-seed-2.0-pro", Kind: "chat"},
{ID: "doubao-seed-2.0", Kind: "chat"},
{ID: "doubao-seed-2.0-flash", Kind: "chat"},
},
},
{
@ -108,8 +108,8 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "MiniMax-M3", Priority: 100, Kind: "chat"},
{ID: "MiniMax-M2.5", Priority: 80, Kind: "chat"},
{ID: "MiniMax-M3", Kind: "chat"},
{ID: "MiniMax-M2.5", Kind: "chat"},
},
},
// ── 国际主流 ────────────────────────────────────────────────────────────
@ -119,9 +119,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "openai",
MaxConcurrent: 8,
Models: []Model{
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
{ID: "gpt-5.6-terra", Priority: 90, Kind: "chat"},
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
{ID: "gpt-5.6-sol", Kind: "chat"},
{ID: "gpt-5.6-terra", Kind: "chat"},
{ID: "gpt-5.6-luna", Kind: "chat"},
},
},
{
@ -130,10 +130,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "anthropic",
MaxConcurrent: 8,
Models: []Model{
{ID: "claude-opus-5", Priority: 100, Kind: "chat"},
{ID: "claude-fable-5", Priority: 95, Kind: "chat"},
{ID: "claude-sonnet-5", Priority: 85, Kind: "chat"},
{ID: "claude-haiku-4-5", Priority: 70, Kind: "chat"},
{ID: "claude-opus-5", Kind: "chat"},
{ID: "claude-fable-5", Kind: "chat"},
{ID: "claude-sonnet-5", Kind: "chat"},
{ID: "claude-haiku-4-5", Kind: "chat"},
},
},
{
@ -142,10 +142,10 @@ var PresetTemplates = []SourceTemplate{
Adapter: "gemini",
MaxConcurrent: 8,
Models: []Model{
{ID: "gemini-3.6-flash", Priority: 100, Kind: "chat"},
{ID: "gemini-3.5-flash", Priority: 90, Kind: "chat"},
{ID: "gemini-3.5-flash-lite", Priority: 80, Kind: "chat"},
{ID: "gemini-3.1-pro", Priority: 85, Kind: "chat"},
{ID: "gemini-3.6-flash", Kind: "chat"},
{ID: "gemini-3.5-flash", Kind: "chat"},
{ID: "gemini-3.5-flash-lite", Kind: "chat"},
{ID: "gemini-3.1-pro", Kind: "chat"},
},
},
{
@ -154,9 +154,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "groq",
MaxConcurrent: 8,
Models: []Model{
{ID: "openai/gpt-oss-120b", Priority: 100, Kind: "chat"},
{ID: "openai/gpt-oss-20b", Priority: 80, Kind: "chat"},
{ID: "groq/compound", Priority: 70, Kind: "chat"},
{ID: "openai/gpt-oss-120b", Kind: "chat"},
{ID: "openai/gpt-oss-20b", Kind: "chat"},
{ID: "groq/compound", Kind: "chat"},
},
},
{
@ -165,9 +165,9 @@ var PresetTemplates = []SourceTemplate{
Adapter: "mistral",
MaxConcurrent: 8,
Models: []Model{
{ID: "mistral-medium-latest", Priority: 100, Kind: "chat"},
{ID: "mistral-large-latest", Priority: 90, Kind: "chat"},
{ID: "codestral-latest", Priority: 85, Kind: "chat"},
{ID: "mistral-medium-latest", Kind: "chat"},
{ID: "mistral-large-latest", Kind: "chat"},
{ID: "codestral-latest", Kind: "chat"},
},
},
{
@ -180,10 +180,10 @@ var PresetTemplates = []SourceTemplate{
"X-Title": "ModelRouter",
},
Models: []Model{
{ID: "openrouter/auto", Priority: 100, Kind: "chat"},
{ID: "deepseek/deepseek-v4-flash", Priority: 90, Kind: "chat"},
{ID: "anthropic/claude-opus-5", Priority: 85, Kind: "chat"},
{ID: "openai/gpt-5.6-sol", Priority: 85, Kind: "chat"},
{ID: "openrouter/auto", Kind: "chat"},
{ID: "deepseek/deepseek-v4-flash", Kind: "chat"},
{ID: "anthropic/claude-opus-5", Kind: "chat"},
{ID: "openai/gpt-5.6-sol", Kind: "chat"},
},
},
{
@ -192,8 +192,8 @@ var PresetTemplates = []SourceTemplate{
Adapter: "github",
MaxConcurrent: 8,
Models: []Model{
{ID: "gpt-5.6-sol", Priority: 100, Kind: "chat"},
{ID: "gpt-5.6-luna", Priority: 80, Kind: "chat"},
{ID: "gpt-5.6-sol", Kind: "chat"},
{ID: "gpt-5.6-luna", Kind: "chat"},
},
},
// ── 本地模型 ────────────────────────────────────────────────────────────
@ -204,9 +204,9 @@ var PresetTemplates = []SourceTemplate{
Endpoint: "/api/chat",
MaxConcurrent: 4,
Models: []Model{
{ID: "qwen3", Priority: 80, Kind: "chat"},
{ID: "deepseek-r1", Priority: 80, Kind: "chat"},
{ID: "llama3.3", Priority: 70, Kind: "chat"},
{ID: "qwen3", Kind: "chat"},
{ID: "deepseek-r1", Kind: "chat"},
{ID: "llama3.3", Kind: "chat"},
},
},
}

View File

@ -0,0 +1,85 @@
package core
import (
"strings"
"testing"
)
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
//
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
//
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
// codewise-default-model-v2 / hunyuan-image-alpha / auto
//
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
// 极易被误判成"这个模型坏了"。
//
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
// 且都是实测依据:
//
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
for _, rule := range []string{
`id == "auto" or id == "default"`, // ①
`id:sub(1, 4) == "nes-"`, // ②
`id:sub(1, 11) == "completion-"`, // ②
`id:sub(1, 9) == "codewise-"`, // ②
`m.supportsExtra == true`, // ②
`mo > 0 and mo <= 256`, // ③
`t == "text-to-image"`, // ④
} {
if !strings.Contains(s, rule) {
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
"删任何一条都会把不可调用模型放回选择器)", rule)
}
}
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
}
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
}
// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。
//
// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回
// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加
// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。
// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。
//
// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。
func TestBuddyCatalogDropsKnownUncallable(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
if !strings.Contains(s, "local NOT_CALLABLE = {") {
t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)")
}
if !strings.Contains(s, `["hy4-preview-x"] = true`) {
t.Error("hy4-preview-x 实测恒 11102,必须排除")
}
// 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后
if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") {
t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)")
}
t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸")
}

View File

@ -151,7 +151,9 @@ func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
}
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
// ★ 一个通道 = 一个源:源名是 `channels-codearts`,**不含账号 id**。
// 按账号 id 找会永远失败 —— 这正是"加账号了但测试说没出源"的假故障。
sm, ok := srcHasAccount(srcs, "codearts")
if !ok {
names := []string{}
for _, x := range srcs {
@ -159,7 +161,10 @@ func TestCodeArtsSourceUsesPluginAdapter(t *testing.T) {
names = append(names, n)
}
}
t.Fatalf("codearts 账号 %q 应已同步源;现有源: %v", aid, names)
t.Fatalf("codearts 账号 %q 应已同步出通道源;现有源: %v", aid, names)
}
if nm, _ := sm["name"].(string); nm != "channels-codearts" {
t.Errorf("通道源名应为 channels-codearts,实得 %q(账号 id=%s)", nm, aid)
}
if ad, _ := sm["adapter"].(string); ad != "codearts" {
t.Errorf("源应引用插件装的 codearts 适配器,实得 %q(签名不会生效)", ad)

View File

@ -0,0 +1,184 @@
package core
import (
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
)
// codearts JWK→PEM 重建判据。
//
// 背景(真实缺陷,2026-10-19 用户报「华为云源用不了」):
//
// 华为云 STS 的 security_token 只活 1 小时,续期(refresh_token 换新)需要
// DPoP 请求签名。签名走 `codeartsDpopSign(key, ...)`,它要求 `key.pem` 是
// **PEM 字符串**(内部用 openssl 命令行签)。而持久化的凭据里**只有**
// `dpop_private_key_jwk`(私钥的 JWK)—— PEM 是登录时的临时文件,没保存。
//
// 于是续期必须先从 JWK 重建 PEM。这条判据钉住"重建出来的 PEM 真能被 openssl
// 接受并签出有效的 ES256 JWS",而不是"函数返回了非空字符串"。
//
// 为什么必须真签名:DER 结构错、长度字段错、base64 换行错,都会得到
// **非空但无效**的 PEM。只断言非空等于什么都没验。
func TestCodeartsRebuildsPemFromJwkAndSigns(t *testing.T) {
// 一个结构合法的 P-256 私钥 JWK(生产凭据的真实形状:三个字段都是
// 32 字节 base64url)。用固定值而非随机生成,是为了让失败可复现。
jwk := map[string]any{
"crv": "P-256",
"kty": "EC",
"d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U",
"x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4",
"y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM",
}
_, ps := poolCore(t)
out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": jwk})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
wrapped, _ := out.(map[string]any)
inner, _ := wrapped["json"].(map[string]any)
if inner == nil {
t.Fatalf("探针返回缺 json 包装: %#v", out)
}
if e, _ := inner["error"].(string); e != "" {
t.Fatalf("JWK→PEM 重建失败: %s", e)
}
// ① PEM 形状必须是 openssl 认的 SEC1 EC PRIVATE KEY
pem, _ := inner["pem"].(string)
if !strings.HasPrefix(pem, "-----BEGIN EC PRIVATE KEY-----") {
t.Fatalf("PEM 头部不对: %q", firstLine(pem))
}
if !strings.Contains(pem, "-----END EC PRIVATE KEY-----") {
t.Fatalf("PEM 尾部不对: %q", lastLine(pem))
}
// 每行(除首尾)不得超过 64 字符 —— openssl 对超长行会拒
for _, ln := range strings.Split(strings.TrimSpace(pem), "\n") {
if strings.HasPrefix(ln, "-----") {
continue
}
if len(ln) > 64 {
t.Fatalf("PEM base64 行长度 %d > 64,openssl 会拒绝", len(ln))
}
}
// ② 最关键:真签名必须成功(DER/长度/换行任一错都在这里暴露)
if ok, _ := inner["signed"].(bool); !ok {
t.Fatalf("PEM 重建后 DPoP 签名失败: %v", inner["sign_error"])
}
// ③ 签名产物必须是三段式 JWS(header.payload.signature)
// 探针不直接回 JWS,但 signed=true 已表示 codeartsDpopSign 走完了
// 整个 openssl 签名 + DER→raw 转换链路(任一环节失败都会返回 nil)。
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func lastLine(s string) string {
s = strings.TrimRight(s, "\n")
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
return s[i+1:]
}
return s
}
// TestCodeartsKeyFromJwkRejectsBadJwk 钉住"坏 JWK 要响亮报错"。
//
// 为什么需要:JWK 字段长度不对(例如被截断、或根本不是 P-256)时,
// 若静默构造出一个畸形 PEM,openssl 只会说「签名失败」—— 运维无从判断
// 是凭据坏了还是代码坏了。必须在这里给可诊断的消息。
func TestCodeartsKeyFromJwkRejectsBadJwk(t *testing.T) {
cases := []struct {
name string
jwk map[string]any
}{
{"d 太短", map[string]any{
"kty": "EC", "crv": "P-256",
"d": "AAAA", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4",
"y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM"}},
{"缺 x", map[string]any{
"kty": "EC", "crv": "P-256",
"d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, ps := poolCore(t)
out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": c.jwk})
if err != nil {
return // 调用层报错也算响亮
}
wrapped, _ := out.(map[string]any)
inner, _ := wrapped["json"].(map[string]any)
if inner == nil {
return
}
if e, _ := inner["error"].(string); e == "" {
if ok, _ := inner["signed"].(bool); ok {
t.Fatalf("坏 JWK 竟然签出了名 —— 应当报错")
}
}
})
}
}
// 让 goimports/json 相关导入不被误判未使用(此文件仅用 json 做类型断言辅助)。
var _ = json.Marshal
var _ = os.Getenv
// TestB64urlDecodeHandlesLongInput 钉住 base64url 解码的**长输入**正确性。
//
// 这个判据补的是一个真实的测漏:我第一版的 b64urlDecode 在输出字节后
// **没有清除已用掉的高位**(缺 `buf = buf % (2^nbits)`)。用 43 字符的
// JWK 字段(32 字节)测时**结果完全正确** —— 于是那条 PEM 判据在变异后
// 依然全绿,什么也没验到。
//
// 只有长输入才暴露:累积缓冲不清高位时,double 精度在约 11 个 6bit 组后
// 耗尽,math.floor 给出越界值,string.char 直接抛 invalid value。
// 实测:把 `string.rep(43字符JWK, 10)` 喂进去,无掩码版本当场崩。
//
// 所以这里必须用**长串**(远超过 11 组),断言解出的字节数与内容都正确。
func TestB64urlDecodeHandlesLongInput(t *testing.T) {
// 96 个字符 → 72 字节。远超溢出临界点(11 组 = 66 bit)。
const in = "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7UBpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"
// 期望值由 Go 标准库算出(不用手写的 Lua 结果做锚,否则自证)
want := base64.RawURLEncoding.EncodeToString
_, ps := poolCore(t)
out, err := ps.Call("channels", "__b64_probe", map[string]any{"s": in})
if err != nil {
t.Fatalf("探针调用失败(很可能是 string.char 越界 = 缺高位掩码): %v", err)
}
wrapped, _ := out.(map[string]any)
inner, _ := wrapped["json"].(map[string]any)
if inner == nil {
t.Fatalf("探针返回缺 json 包装: %#v", out)
}
got, _ := inner["hex"].(string)
n, _ := inner["len"].(float64)
// 期望长度由 Go 自己算(不要手推 len/4*3:输入不一定是 4 的倍数)
dec, derr := base64.RawURLEncoding.DecodeString(in)
if derr != nil {
t.Fatal(derr)
}
if int(n) != len(dec) {
t.Fatalf("解码长度 = %v,期望 %d", n, len(dec))
}
// 与 Go 解码结果逐字节比对
wantHex := ""
for _, b := range dec {
wantHex += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0xf])
}
if got != wantHex {
t.Fatalf("解码内容不符(长输入下高位未清除):\n got=%s\nwant=%s", got, wantHex)
}
_ = want
}

View File

@ -37,6 +37,9 @@ func TestDeadModelGoneJudgement(t *testing.T) {
{"The model does not exist", true, "does not exist"},
{"error: model_not_found", true, "独占一句话(行尾)"},
{"model_not_found.", true, "后面是句末标点"},
{"model is not registered", true, "CodeArts/InferHub 下架模型的真实文案"},
{"InferHub.002002009.404: The model is not registered, please request other model", true,
"CodeArts 完整 404 文案"},
// ── 阴性:看着像但**不是**下架 ──
{"error_class=model_not_found, retry allowed", false,
"字段名后面还有内容(初版误判下架,实测教训)"},

View File

@ -0,0 +1,149 @@
package core
import (
"strings"
"testing"
)
// 渠道端点判据 —— 钉住"base_url 不吞 endpoint 路径"这条铁律。
//
// 为什么需要这个测试
// ------------------
// 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。把路径**同时**
// 写进 base_url 和 endpoint,拼出来就是重复路径。生产实测两次撞上同一族缺陷:
//
// zcode base_url=.../api/v1/zcode-plan/anthropic
// endpoint=/api/v1/zcode-plan/anthropic/v1/messages
// ⇒ .../anthropic/api/v1/zcode-plan/anthropic/v1/messages → 404
//
// minimax base_url=https://agent.minimax.cn + adapter=openai(默认 endpoint
// /chat/completions)⇒ 打到 Web 前端首页,404「简单指令,无限可能」
//
// 两者报错都是 404,但根因不同(路径重复 / 缺路径段)。单条断言咬不住两种,
// 所以下面分三条断言。
// platformDef 从**真实加载的插件**里读一个平台定义。
//
// ★ 必须走真实加载而不是正则解析源码:PLATFORMS 是 Lua 表,源里有注释、
// 条件拼装、以及文件末尾的绑定循环(refresh_fn / claim_fn / headers_fn 都在
// 那里挂)。拿正则在源文本上判断"有没有 refresh 段"会被注释里的字样骗 ——
// 这个坑我上一轮差点踩进去。探针返回的是插件运行时看到的真相。
func platformDef(t *testing.T, id string) map[string]any {
t.Helper()
_, ps := poolCore(t)
out, err := ps.Call("channels", "__platform_def_probe", map[string]any{"id": id})
if err != nil {
t.Fatalf("__platform_def_probe(%s) 失败: %v", id, err)
}
// Plugins.Call 返回 {json:{...}, status:...} 包装(历史约定)。
wrapped, ok := out.(map[string]any)
if !ok {
t.Fatalf("探针返回不是表: %T", out)
}
if e, ok := wrapped["error"].(string); ok && e != "" {
t.Fatalf("%s: %s", id, e)
}
inner, ok := wrapped["json"].(map[string]any)
if !ok {
t.Fatalf("探针返回缺 json 包装: %#v", wrapped)
}
return inner
}
// TestChannelBaseURLDoesNotSwallowEndpoint 是最核心的一条:
// base_url 里**不得**出现 endpoint 也有的路径段。
//
// 判别性:zcode 缺陷时 base_url 尾段是 "anthropic",endpoint 里也含
// "/anthropic/" ⇒ 命中。修好后 base_url 只到 host,永不命中。
func TestChannelBaseURLDoesNotSwallowEndpoint(t *testing.T) {
for _, id := range []string{"zcode", "minimax", "codearts", "trae", "lobsterai", "buddy"} {
t.Run(id, func(t *testing.T) {
p := platformDef(t, id)
base, _ := p["base_url"].(string)
ep, _ := p["endpoint"].(string)
base = strings.TrimRight(base, "/")
if base == "" {
t.Fatalf("%s 没有 base_url", id)
}
// 从 base_url 里取出路径部分(host 之后),逐段看是否也出现在 endpoint 里。
pathPart := ""
if i := strings.Index(base, "://"); i >= 0 {
if j := strings.Index(base[i+3:], "/"); j >= 0 {
pathPart = base[i+3+j:]
}
}
if pathPart == "" {
return // 只到 host,天然不会重复
}
for _, seg := range strings.Split(strings.Trim(pathPart, "/"), "/") {
if seg == "" {
continue
}
if strings.Contains(ep, "/"+seg+"/") || strings.HasSuffix(ep, "/"+seg) {
t.Fatalf("%s: base_url 已含路径段 %q(base_url=%s),"+
"endpoint 里又出现(endpoint=%s)⇒ 内核拼 base_url+endpoint 时路径重复",
id, seg, base, ep)
}
}
})
}
}
// TestMinimaxUsesAnthropicProtocolAndRealInferPath 钉住 minimax 的两层缺陷。
//
// 参考 channel-pack/src/minimax-product.ts:
//
// MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages' ← Anthropic Messages
// apiHost = 'https://agent.minimax.cn'
//
// 缺陷 A:base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions
//
// ⇒ 那是 Web 前端,404 首页。实测(2026-10-19):
// /mavis/api/v1/llm/v1/messages → 401 invalid access token(路径对)
// 裸 /chat/completions → 404 page not found
// /mavis/api/v1/chat/completions → 404
//
// 缺陷 B:即使路径改对,openai 适配器的**请求体形状**也不对
//
// (minimax 推理端点是 Anthropic 协议)。
func TestMinimaxUsesAnthropicProtocolAndRealInferPath(t *testing.T) {
p := platformDef(t, "minimax")
ep, _ := p["endpoint"].(string)
adapter, _ := p["adapter"].(string)
if !strings.HasSuffix(ep, "/mavis/api/v1/llm/v1/messages") {
t.Errorf("minimax endpoint = %q,应为 /mavis/api/v1/llm/v1/messages"+
"(照参考 MINIMAX_INFER_PATH);否则请求会打到 Web 前端", ep)
}
if adapter != "anthropic" {
t.Errorf("minimax adapter = %q,应为 anthropic —— 推理端点是 Anthropic "+
"Messages 协议,用 openai 适配器会把请求体拼错", adapter)
}
}
// TestCodeartsDeclaresRefreshHook 钉住"声明了可续期就必须声明怎么续"。
//
// 真实缺陷(2026-10-19 用户报「华为云源用不了」):
// codearts 的 security_token 只活 1 小时;account_refresh 走 refreshCredential(),
// 它要求 plat.refresh.url 或 plat.refresh_fn 存在。当时只声明了 refreshable=true
// (那是 accountView 里硬编码的期望),**没声明怎么续** ⇒ 恒出
// 「该平台未声明续期方式」,自动续期链空转,日志反复 `模型刷新失败 HTTP 400`。
//
// 为什么不能只看 refreshable:那是"这个账号的凭据类型可续",与"平台实现了
// 续期"是两件事。前者为真而后者缺失,正是这个 bug 的形状。
func TestCodeartsDeclaresRefreshHook(t *testing.T) {
p := platformDef(t, "codearts")
hasHook, _ := p["has_refresh_fn"].(bool)
hasDecl, _ := p["has_refresh_decl"].(bool)
if !hasHook && !hasDecl {
t.Fatal("codearts 既无 refresh_fn 也无 refresh.url —— " +
"account_refresh 会恒返回「该平台未声明续期方式」," +
"而 STS token 只有 1 小时寿命,过期后源的每个请求都会 400")
}
// 华为云 STS 要 DPoP 头,声明式 style 拿不到私钥 ⇒ 必须是钩子
if !hasHook {
t.Error("codearts 只有声明式 refresh.url,但华为云 STS 需要 DPoP 头" +
"(jwk 在凭据里),声明式 style 发不出来 ⇒ 必须用 refresh_fn")
}
}

View File

@ -0,0 +1,76 @@
package core
import (
"regexp"
"strings"
"testing"
)
// ★ 源的 chat URL = `base_url + endpoint`(内核 provider.go ChatURL)。
//
// 真实缺陷(生产实测):多个渠道平台的 `base_url` 里塞了**完整路径**,
// 而适配器默认 endpoint 又是 "/chat/completions" ⇒ 实际请求变成
//
// https://copilot.tencent.com/v2/chat/completions/chat/completions
//
// ⇒ 上游回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞,不是模型错误)。
//
// 受影响平台 8 个:buddy / cline / lobsterai / opencode / qoder / loomy /
// raccoon(zcode 当时已用 endpoint 覆盖,形态正确)。
//
// ★ 为什么这个缺陷能活很久:**模型名被多个源服务**。
//
// `deepseek-v4.1-flash` 同时由 4 个源提供(alittokenplan/vela/oin/channels-buddy…)
// ⇒ 网关在 buddy 源失败后会**继续尝试下一个源**并成功 ⇒ 用户看到"能回话",
// 于是"buddy 通道是好的"这个结论**完全没有证据**。
// 而 `hy3` 只由 buddy 一个源提供 ⇒ 一调就暴露真因。
// ⇒ 判据必须断言**源的 URL 形态**(结构不变量),不能靠"某个模型能调通"。
//
// 判据:base_url 不得自带任何 chat 路径段(它只应是 host[+协议公共前缀]),
// 路径必须在 `endpoint` 里。
func TestChannelSourceBaseURLHasNoChatPath(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// 只在平台定义区里找 base_url(避免误判注释/文档里的示例)
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
// 已知适配器会把这些路径各追加一次;出现即说明 base_url 侵占了 endpoint 的地盘
banned := []string{"/chat/completions", "/v1/messages", "/generateContent", "/api/chat"}
found := 0
for _, m := range declRe.FindAllStringSubmatch(s, -1) {
url := m[1]
found++
for _, b := range banned {
if strings.Contains(url, b) {
t.Errorf("base_url 侵占了 endpoint 的路径:%s\n"+
" 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),"+
"适配器默认 endpoint=\"/chat/completions\" ⇒ 实际会请求 %s%s\n"+
" 修法:base_url 只留 host,把 `%s` 移到该平台的 `endpoint = \"...\"`",
url, url, b, b)
}
}
}
if found == 0 {
t.Fatal("没扫到任何 base_url 声明(判据失效)")
}
t.Logf("✓ %d 个 base_url 均不含 chat 路径段", found)
}
// ★ 反过来也要咬:说明该缺陷**确实已被修好**、而不是判据太松。
// 用一段**故意的坏数据**验证判据的判别力(变异测试的静态等价物)。
func TestChannelBaseURLJudgeBitesOnBadInput(t *testing.T) {
bad := ` base_url = "https://copilot.tencent.com/v2/chat/completions",`
declRe := regexp.MustCompile(`(?m)^\s*base_url = "([^"]+)"\s*,?$`)
m := declRe.FindStringSubmatch(bad)
if m == nil {
t.Fatal("判据的正则匹配不到坏数据 ⇒ 判据无判别力(形同虚设)")
}
if !strings.Contains(m[1], "/chat/completions") {
t.Fatal("坏数据未被识别 ⇒ 判据无判别力")
}
t.Log("✓ 判据对坏数据有判别力")
}

View File

@ -36,10 +36,12 @@ func TestChannelSourcesAreSerialWithThrottle(t *testing.T) {
aid, _ := j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
// 一个通道 = 一个源:源名不含账号 id,按**本用例实际用的平台**找。
sm, ok := srcHasAccount(srcs, "buddy")
if !ok {
t.Fatal("账号未出源")
t.Fatalf("账号未出通道源,实际源: %v", srcs)
}
_ = aid
// ① 串行
if mc, _ := toFloat(sm["max_concurrent"]); mc != 1 {
t.Errorf("源必须 max_concurrent=1(并发发起必然浪费一个配额),实得 %v", sm["max_concurrent"])

View File

@ -0,0 +1,105 @@
package core
import (
"testing"
)
// ★ 升级后**旧版按账号拆的源必须被清掉**,否则同一个渠道的模型出现两遍。
//
// 真实事故(2026-10-19 用户报「同一个模型两个名字 / 源列表一堆不可读的东西」):
// 生产 config.yaml 里 6 对源并存 ——
//
// channels-trae-b8dd6b3df30e + channels-trae
// channels-buddy-buddy-main + channels-buddy
// …(trae/lobsterai/minimax/buddy/codearts/zcode 各一对)
//
// 新设计是「一通道一源」(`channels-<provider>`),换号靠同名 add_source 覆盖
// api_key。旧版是「一账号一源」(`channels-<provider>-<账号id>`)。升级时必须把
// 旧形态清掉,否则:
//
// ① 模型列表里同一模型出现多遍,只能靠 12 位 hex 区分(人读不出来)
// ② 旧源指向已不该用的账号,却可能因为注册更早而**先**被调度到
//
// 根因是形态假设错了:内核 `list_sources` 返回**裸数组**(见 kernel_actions.go
// 的 actionListSources,直接 `return out`),而迁移代码按 `{sources:[...]}`
// 信封去取 ⇒ 恒为 nil ⇒ 循环一次都不跑 ⇒ 静默什么都没清。
//
// ★ 本判据的关键是**用裸数组形态喂数据**(也就是内核真实返回的形状)。
//
// 如果按信封造夹具,即使代码是错的两边也"对上了",测不出任何东西 ——
// 这正是这个 bug 逃过全部既有判据的原因。
func TestChannelsLegacyPerAccountSourceIsCleaned(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)
}
if err := ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
t.Fatalf("授权: %v", err)
}
// 造存量:一个旧形态源(带账号 hex)+ 一个别的通道的旧源(不该被误删)。
mk := func(name string) {
if _, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
"name": name,
"base_url": "https://example.invalid",
"adapter": "openai",
"api_key": "sk-x",
"models": []interface{}{"m1"},
}); err != nil {
t.Fatalf("造源 %s: %v", name, err)
}
}
mk("channels-trae-b8dd6b3df30e") // 旧形态:trae 的某个账号
mk("channels-trae-deadbeef0123") // 旧形态:trae 的另一个账号
mk("channels-buddy-buddy-main") // 别的通道 —— 不该被 trae 的同步碰到
mk("unrelated-source") // 与 channels 无关,绝不能被删
// 放一个**真实**的 trae 账号(走 account_add,不是探针):没有账号就无从
// 写源,也就走不到「写前先清旧源」那一步。account_add 本身也会同步一次源,
// 但那正是要测的路径。
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "trae", "account_id": "b8dd6b3df30e",
"credential": `{"access_token":"T","refresh_token":"R","nickname":"n"}`,
}); err != nil {
t.Fatalf("造账号: %v", err)
}
// ★ 只走 `syncAccountSource` → `syncProviderSource` 这条路(迁移清理就在这里)。
// 刻意**不用** reap_orphan_sources:它自己另有一套"形态 1 旧源"清理逻辑,
// 会把这里要测的迁移代码完全遮住 —— 用它做判据时,即使迁移代码是坏的
// (信封假设的 bug 仍在)判据也会绿,等于没测。
if _, err := ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
"platform": "trae",
}); err != nil {
t.Fatalf("resync: %v", err)
}
names := map[string]bool{}
for _, s := range c.Sources() {
names[s.Name] = true
}
for _, legacy := range []string{"channels-trae-b8dd6b3df30e", "channels-trae-deadbeef0123"} {
if names[legacy] {
t.Errorf("旧形态源 %s 没被清掉 —— 同一渠道的模型会在列表里出现两遍,"+
"只能靠 12 位 hex 区分,用户读不出哪个是哪个", legacy)
}
}
if !names["channels-trae"] {
t.Error("新形态源 channels-trae 没建起来")
}
if names["unrelated-source"] != true {
t.Error("清理误伤了无关源 unrelated-source")
}
// ★ 这里才是真的越界判据:trae 的同步**只**该清 `channels-trae-` 前缀的旧源。
// 如果前缀匹配写成贪婪/过宽(例如 `channels-(.+)-`),buddy 的源会被误删。
if !names["channels-buddy-buddy-main"] {
t.Error("清理越界:trae 的同步删掉了 buddy 的源(前缀匹配过宽)")
}
}

View File

@ -0,0 +1,198 @@
package core
import (
"regexp"
"strings"
"testing"
)
// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。
//
// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务
// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮,
// **真正的用户动作**(如「账号删除」)被挤到看不见。
//
// 三个结构性修法,每个都在源码里可断言:
//
// ① logEvent 支持 level(task/warn/error),前端默认折叠 task
// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误)
// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1
func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 签名必须收 level 与 platform(三参)
if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) {
t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达")
}
// ② 写入的条目必须带上这两个字段
for _, field := range []string{"level = level or", "platform = platform"} {
if !strings.Contains(s, field) {
t.Errorf("日志条目必须写入 %s", field)
}
}
// ③ 同文折叠:紧邻同文时只加计数
if !strings.Contains(s, "top.n = (top.n or 1) + 1") {
t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)")
}
// ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前,
// 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。
if !strings.Contains(s, "local top = log[1]") {
t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱")
}
t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠")
}
// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。
//
// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" ——
// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。
func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// 这些是「每次跑都成功」的常规回音 —— 必须 task 级
routine := []string{
`logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`,
`logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`,
`logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`,
}
for _, r := range routine {
if !strings.Contains(s, r) {
t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r)
}
}
// 这些是异常 —— 必须 warn(不能被一起折叠掉)
for _, pat := range []string{
`logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`,
`logEvent("凭据续期失败 " .. #failed .. " 个:`,
} {
if !strings.Contains(s, pat) {
t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat)
}
}
// 「已同步源」是周期重同步的常规回音 —— 降级为 task 且带平台归属。
// 文案在"一个通道=一个源"重构后多带了账号 id(便于排查"用的是哪个号"),
// 故这里只钉住"task 级 + 带 provider"这两个**语义**要件,不钉住整句字面量 ——
// 钉字面量会让每次改文案都要改测试,而这条测试要防的是"级别升回 info"。
if !strings.Contains(s, `logEvent("已同步源 " .. name`) {
t.Error("「已同步源」这条回音丢了(改名了?)")
}
// 匹配"已同步源 ... "task", provider)"——跨行(文案带账号 id 后跨了两行),
// 故用 (?s) 让 . 能匹配换行。
if !regexp.MustCompile(`(?s)logEvent\("已同步源 " \.\. name.*?"task", provider\)`).MatchString(s) {
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属(provider)")
}
t.Log("✓ 周期噪音已降级,异常保持 warn")
}
// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。
func TestChannelsLogPanelHasFilters(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属)
// 判据:日志卡片的 DOM 不是 ch-detail 的子元素。
// 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过
// ch-detail 的闭合(即两者是兄弟而非父子)。
iDetail := strings.Index(s, `id="ch-detail"`)
iLogCard := strings.Index(s, `id="ch-log-card"`)
if iDetail < 0 || iLogCard < 0 {
t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)")
}
if iLogCard < iDetail {
t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)")
}
// 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数
seg := s[iDetail:iLogCard]
depth := 0
for _, m := range regexp.MustCompile(`</?div`).FindAllString(seg, -1) {
if strings.HasPrefix(m, "</") {
depth--
} else {
depth++
}
}
if depth > 0 {
t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth)
}
// ② 三个过滤维度
for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} {
if !strings.Contains(s, id) {
t.Errorf("缺少日志视图控件 %s", id)
}
}
if !strings.Contains(s, "function chLogRender()") {
t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)")
}
// ③ 按渠道过滤 + 切渠道重渲染
if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") {
t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)")
}
if !strings.Contains(s, "chLogRender();\n };\n") {
t.Error("chSelect 切换渠道后必须重渲染日志")
}
t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备")
}
// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。
//
// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成
//
// `能力增强不<E5BCBA>`
//
// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节
// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。
func TestLogTruncationIsUTF8Safe(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 必须存在按字符截断的助手
if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") {
t.Fatal("必须实现 plugin.utf8Clip(按字符截断)")
}
// ② 中文文案上的截断不得再用裸 sub()
for _, bad := range []string{
`tostring(cres):sub(1, 120)`,
`tostring(cerr):sub(1, 120)`,
`table.concat(failed, "; "):sub(1, 300)`,
} {
if strings.Contains(s, bad) {
t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad)
}
}
// ③ 三处必须已换成 utf8Clip
for _, want := range []string{
"plugin.utf8Clip(cres, 120)",
"plugin.utf8Clip(cerr, 120)",
`plugin.utf8Clip(table.concat(failed, "; "), 300)`,
} {
if !strings.Contains(s, want) {
t.Errorf("缺少 UTF-8 安全截断调用:%s", want)
}
}
// ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短)
if !strings.Contains(s, "if #s <= maxBytes then return s end") {
t.Error("utf8Clip 必须对未超长的输入原样返回")
}
// ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节
if !strings.Contains(s, "elseif b >= 0xC0 then") {
t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)")
}
t.Log("✓ 截断已 UTF-8 安全")
}

View File

@ -0,0 +1,73 @@
package core
import (
"os/exec"
"regexp"
"strings"
"testing"
)
// ★ channels.lua 必须留出顶层 local 余量 —— 否则**下一次顶层改动就编译失败**。
//
// 实测事实(2026-10-16):该文件的主 chunk 曾**顶到 Lua 上限 200**,
// 往顶部加**哪怕 1 个**顶层 local 就报
//
// main function has more than 200 local variables
//
// 而这炸的不是某个功能 —— 是**整个渠道插件**(插件加载失败 ⇒ 全渠道不可用)。
//
// ⚠️ 判据为什么用 luajit 而不是 luac:
//
// 内核链接的是 libluajit-5.1.so.2;而 `luac`(Lua 5.4)对同一文件给出
// **不同的**结论。用错工具会得到假绿。
//
// ⚠️ 判据为什么是「注入 N 个 local 仍能编译」而不是「数一遍 local」:
//
// 主 chunk 的 local 数受**作用域嵌套**影响,静态计数不可靠(同一个文件
// 用不同脚本数出 228/232/248 三个数)。而"能不能再加"是**决定性**的,
// 也正是我们真正需要的性质。
func TestChannelsLuaHasLocalHeadroom(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if _, err := exec.LookPath("luajit"); err != nil {
t.Skip("luajit 不在 PATH(判据需要与内核相同的 Lua 实现)")
}
const headroom = 10
for _, n := range []int{1, headroom} {
probe := strings.Repeat("local __headroom = 1\n", n) + src
cmd := exec.Command("luajit", "-e", "local f,e=loadfile(arg[1]); if not f then io.stderr:write(tostring(e)) os.exit(1) end", "/dev/stdin")
cmd.Stdin = strings.NewReader(probe)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("★ 顶层 local 余量不足:加 %d 个 local 就无法编译 ⇒ "+
"下一次顶层改动会**整插件加载失败**(全渠道不可用)\n %s\n"+
"修法:把纯常量收进 plugin.K 表(挂表字段不占 local 槽位)", n, strings.TrimSpace(string(out)))
}
}
t.Logf("✓ channels.lua 仍有 ≥%d 个顶层 local 余量", headroom)
}
// 配对判据:常量必须确实在 plugin.K 里(防止有人把余量"省"出来的同时又
// 把常量改回裸 local —— 那会让下一个改动的余量再次归零)。
func TestChannelsConstantsLiveInPluginK(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
if !strings.Contains(s, "plugin.K = {") {
t.Fatal("必须有 plugin.K 常量表(它是 local 余量的来源)")
}
// 抽样:这些曾占用 local 槽位的纯常量必须已挂表
for _, n := range []string{"STORE_NAME", "SESSION_TTL", "B64_ALPHABET", "MODELS_DEV_URL"} {
if !regexp.MustCompile(`(?m)^\s+` + n + ` = `).MatchString(s) {
t.Errorf("%s 应定义在 plugin.K 表内", n)
}
if regexp.MustCompile(`(?m)^local ` + n + ` = `).MatchString(s) {
t.Errorf("%s 又变回裸 local 了(会吃掉 local 余量)", n)
}
}
t.Log("✓ 纯常量已集中在 plugin.K")
}

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* 覆盖,
// 而我们要的恰恰是"源留下了"这个状态。
//
// ★ 一通道一源之后:`account_add` 产生的源名是 `channels-buddy`(不含账号 id),
// 所以它**造不出**本用例要的「旧版按账号拆源」形态。这里直接用
// __add_source_for_test 注册遗留形态的源名 —— 这才是要验的对象本身
// (reap 的职责就是清掉升级前遗留的这类源)。
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
"name": "channels-buddy-deadbeef1234", "base_url": "http://127.0.0.1:1",
"adapter": "openai", "models": []interface{}{"m1"},
}); err != nil {
t.Fatalf("注册遗留源(1): %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
t.Fatalf("注册遗留源(1) 报错: %v", j["error"])
}
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
"name": "channels-buddy-dead-acct-1", "base_url": "http://127.0.0.1:1",
"adapter": "openai", "models": []interface{}{"m1"},
}); err != nil {
t.Fatalf("注册遗留源(2): %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
t.Fatalf("注册遗留源(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"])
}
// 遗留源对应的账号本来就不存在(这正是"孤儿"的定义),无需再摘。
// ① 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

@ -120,9 +120,17 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
t.Run("模型开关同步上游源", func(t *testing.T) {
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
aid, _ := m["account_id"].(string)
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
// ★ 一个通道 = 一个源:「加账号」同步出的是**通道级**源 `channels-<platform>`,
// 源名里**不含**账号 id(这正是本次重构的目的 —— 换号对客户端透明,
// 用户书签里的 `源:模型` 不会因为换号而失效)。
// 故这里按平台名找源,不按 aid 找。
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax")
if !ok {
t.Fatal("带凭据账号应已同步源")
t.Fatalf("带凭据账号应已同步出通道源,实际源: %v", listSourcesViaKernel(t, c))
}
// 而且源名必须是通道级那个(旧版会带 -<12hex> 后缀)
if nm, _ := sm["name"].(string); nm != "channels-minimax" {
t.Fatalf("通道源名应为 channels-minimax,实得 %q(账号 id=%s)", nm, aid)
}
// 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
// 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
@ -133,7 +141,8 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
// 同样按平台名找(源名不含账号 id)
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), "minimax")
if n := len(asArr(sm["models"])); n != total-1 {
t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
}
@ -155,14 +164,43 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
t.Fatal("上个用例应留有账号")
}
aid := accts[0].(map[string]interface{})["id"].(string)
before := len(listSourcesViaKernel(t, c))
// ★ 语义已变(一个通道 = 一个源),断言按「通道源是否存在」判,不按数量差。
//
// 为什么不能断言 `after == before-1`:内核的 remove_source 有一条正当
// 保护 —— **拒绝删掉最后一个源**(空 registry 会让每个请求变 503)。
// 本夹具只有这一个源,撤源请求会被这条保护挡住,数量差恒为 0,
// 于是断言无论实现对错都失败。生产上有 20+ 源不会撞这条保护,
// 但判据不能依赖"恰好有别的源"这种环境事实。
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
t.Fatal("前置:minimax 通道源应存在")
}
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
// 停用唯一账号 ⇒ 该通道无可用号 ⇒ 插件**尝试**撤源。夹具里会撞上内核
// 「拒绝删最后一个源」的保护,故不断言源数量,改断言插件自己的判据:
// provider_status 里该通道的 enabled 应转为 false(无可用账号)。
// ⚠️ provider_status 的字段形状:`enabled` 是**嵌套在 accounts 里的计数**
// (accounts = {total=N, enabled=M}),不是顶层布尔。顶层只有 `closed`
// (表示"该平台所有模型都被关掉")。写断言前必须看清形状 ——
// 我第一版按顶层 `enabled == false` 判,永远不成立。
st := invoke(t, ps, "provider_status", `{}`)
noUsable := false
for _, x := range asArr(st["statuses"]) {
row := x.(map[string]interface{})
if row["id"] != "minimax" {
continue
}
if acc, ok := row["accounts"].(map[string]interface{}); ok {
if n, _ := acc["enabled"].(float64); n == 0 {
noUsable = true
}
}
}
if !noUsable {
t.Fatalf("停用唯一账号后,minimax 的 accounts.enabled 应为 0(无可用账号)")
}
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
if n := len(listSourcesViaKernel(t, c)); n != before {
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
if n := len(listSourcesViaKernel(t, c)); n < 1 {
t.Fatalf("启用账号后应重建源,实际源数 %d", n)
}
})

View File

@ -49,16 +49,17 @@ func TestPrivateProtocolSourcesUsePluginAdapters(t *testing.T) {
if j == nil || j["error"] != nil {
t.Fatalf("account_add 报错: %#v", m)
}
aid, _ := j["account_id"].(string)
_, _ = j["account_id"].(string)
srcs := listSourcesViaKernel(t, c)
sm, ok := srcHasAccount(srcs, aid)
// ★ 一通道一源:按**平台名**找源,不按账号 id(源名已不含 id)。
sm, ok := srcHasAccount(srcs, tc.platform)
if !ok {
names := []string{}
for _, x := range srcs {
names = append(names, x["name"].(string))
}
t.Fatalf("%s 账号加了却不出源(现有源: %v)—— 源注册失败通常是"+
t.Fatalf("%s 账号加了却不出通道源(现有源: %v)—— 源注册失败通常是"+
"凭据形状不符或内核权限未授", tc.platform, names)
}
ad, _ := sm["adapter"].(string)

View File

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@ -220,3 +221,75 @@ func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
})
}
}
// 裸 token 凭据必须能领:qoder 是 PKCE 设备码流且无 register 第二跳,
// complete_login 存的就是裸 access_token 字符串(真实生产事故:账号池凭据是
// dt-… 裸串,claim 报「缺少凭据对象」,而 account_test 用 token 路径照常通过
// —— 自相矛盾的状态)。活动接口只要 Bearer token,就地包一层即可。
func TestQoderClaimAcceptsBareTokenCredential(t *testing.T) {
var auth string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
auth = r.Header.Get("Authorization")
switch {
case r.URL.Path == "/sash/api/v1/me/campaigns":
fmt.Fprint(w, `{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`)
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
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)
}
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 与生产一致:凭据是**裸 token 字符串**(不是 JSON 对象)。
body, _ := json.Marshal(map[string]string{
"platform": "qoder", "credential": "dt-D6t0cFLOY3fpSmu2Mck2WWeO", "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome,实得 %v", j)
}
// claim_daily 的通用路径把 claim_fn 的 kind=claimed 归一成 success + message;
// 判据是**不再是 failed** 且消息带领取金额。
kind, _ := oc["kind"].(string)
if kind == "failed" {
t.Fatalf("裸 token 凭据应能领取,实得 kind=failed(%v)", oc["message"])
}
if msg, _ := oc["message"].(string); !strings.Contains(msg, "100") {
t.Errorf("领取成功消息应带金额,实得 %q", msg)
}
if auth != "Bearer dt-D6t0cFLOY3fpSmu2Mck2WWeO" {
t.Errorf("活动接口必须带 Bearer 裸 token,实得 %q", auth)
}
}

View File

@ -0,0 +1,215 @@
package core
import (
"regexp"
"sort"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// ★ 删除账号必须**真的把源撤掉** —— 且撤不掉时不能静默。
//
// 真实缺陷:内核的 `remove_source` 需要**单独授予**(Destructive 级,不在
// add_source 的授权里)。生产权限集曾只有 add_source ⇒ `dropAccountSource`
// 的调用被**拒绝**,而调用方**忽略返回值** ⇒ 静默失败的后果是
// **账号已删,它的源仍在对外服务**(用户看到"删了却还在用")。
//
// 格外隐蔽的一点:`AddSource` 是**同名 upsert**,所以 `syncAccountSource` 里
// "先 remove 再 add" 的 remove 失败**看不出来**(add 覆盖掉同名源)——
// 只有**纯删除**路径(account_delete / 停用)才暴露。故判据必须走删除路径。
func TestAccountDeleteRemovesItsSource(t *testing.T) {
t.Run("授予 remove_source 时应撤掉源", func(t *testing.T) {
h := newRemoveSourceHarness(t, true)
h.addAccount("del-a")
if got := h.sourceNames(); !hasName(got, "channels-buddy") {
t.Fatalf("前置条件:通道源应已建立(一通道一源:channels-buddy),实得 %v", got)
}
h.deleteAccount("del-a")
if got := h.sourceNames(); hasName(got, "channels-buddy") {
t.Fatalf("唯一账号已删但通道源仍在 ⇒ 已删账号继续对外服务:%v", got)
}
t.Log("✓ 删除唯一账号同时撤掉通道源")
})
t.Run("未授予 remove_source 时必须**可见地**失败(不能静默)", func(t *testing.T) {
h := newRemoveSourceHarness(t, false)
h.addAccount("del-b")
h.deleteAccount("del-b")
// 源确实撤不掉(权限不足)——这是环境的限制,不是我们要判的
// 我们要判的是:**这件事必须被记录下来**,而不是消失在一个被丢弃的返回值里。
logs := h.eventLog()
joined := strings.Join(logs, " | ")
if !strings.Contains(joined, "撤旧源失败") && !strings.Contains(joined, "撤源失败") {
t.Fatalf("撤源被拒却没有任何记录 ⇒ 权限缺失会一直静默(表现是"+
"「删了账号却还在用」)\n事件日志: %s", joined)
}
if !strings.Contains(joined, "remove_source") {
t.Fatalf("记录里应点明是哪个内核动作被拒(便于给运维可操作提示),实得: %s", joined)
}
// ★ 必须带上**真正的原因**(not granted),而不是"无返回"这类空话。
// 内核桥失败时**第一个返回值是 nil、原因在第二个返回值里** ——
// 只接第一个返回值就会把原因丢掉(我第一版正是如此)。
if !strings.Contains(joined, "not granted") {
t.Fatalf("记录里必须含内核给出的**真实原因**(not granted),"+
"否则运维无从下手(只接 call 的第一个返回值就会丢掉原因);实得: %s", joined)
}
t.Logf("✓ 撤源失败已可见: %s", joined)
})
}
// ── harness ──
type removeSourceHarness struct {
t *testing.T
ps *lua.Plugins
}
func newRemoveSourceHarness(t *testing.T, grantRemove bool) *removeSourceHarness {
t.Helper()
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)
}
// ★ 照生产现状:add_source 有、remove_source 视参数而定
// 注意:status 是插件自己的导出,与内核权限无关(之前误当权限项)
actions := []string{"add_source", "list_sources"}
if grantRemove {
actions = append(actions, "remove_source")
}
_ = ps.SetPluginKernelPermission("channels", actions, false)
return &removeSourceHarness{t: t, ps: ps}
}
func (h *removeSourceHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
}
func (h *removeSourceHarness) deleteAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_delete", map[string]interface{}{
"platform": "buddy", "account_id": id,
}); err != nil {
h.t.Fatalf("account_delete(%s): %v", id, err)
}
}
func (h *removeSourceHarness) sourceNames() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["sources"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if n, ok := m["name"].(string); ok {
out = append(out, n)
}
}
}
return out
}
// eventLog 取插件事件日志(logEvent 写进 plugin.state.log)。
// ⚠ 走探针:`status` 是 HTTP 路由处理器,**不在** plugin.exports 里,
// 直接 Call 它会报 `does not export "status"`(我第一版就这么错)。
func (h *removeSourceHarness) eventLog() []string {
h.t.Helper()
res, err := h.ps.Call("channels", "__event_log_probe", map[string]interface{}{})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
var out []string
arr, _ := j["log"].([]interface{})
for _, e := range arr {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func hasName(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
// ★ 插件**声明**的内核动作必须覆盖它**实际调用**的全部动作。
//
// 真实缺陷:`plugin.kernel` 只声明了 5 个动作,而插件在 5 处调用 `remove_source`
// —— 声明与使用**漂移**。生产只授了声明过的那些 ⇒ 撤源全部被拒,且因为
// `AddSource` 是**同名 upsert**(add 会覆盖,看不出 remove 失败),
// 只有**纯删除路径**才暴露:**账号删了,它的源还在对外服务**。
//
// 判据用**静态扫描**(源码里出现的 call("X"))对比声明清单 —— 这样将来任何人
// 加了新的内核调用却忘了声明,都会在这里报红,而不是等线上静默失效。
func TestChannelsDeclaresEveryKernelActionItCalls(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// ① 抽出声明清单
declStart := strings.Index(s, "plugin.kernel = {")
if declStart < 0 {
t.Fatal("找不到 plugin.kernel 声明(判据需更新)")
}
declEnd := strings.Index(s[declStart:], "}")
if declEnd < 0 {
t.Fatal("plugin.kernel 声明没有收尾大括号")
}
declBlob := s[declStart : declStart+declEnd]
declared := map[string]bool{}
for _, m := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(declBlob, -1) {
declared[m[1]] = true
}
if len(declared) == 0 {
t.Fatal("声明清单解析为空(判据失效)")
}
// ② 抽出实际调用的动作
called := map[string]bool{}
for _, m := range regexp.MustCompile(`__llmsproxy_kernel\.call\(\s*"([a-z_]+)"`).FindAllStringSubmatch(s, -1) {
called[m[1]] = true
}
if len(called) == 0 {
t.Fatal("没扫描到任何内核调用(判据失效)")
}
// ③ 逐个比对:调用而未声明 = 漏声明(运维照声明授权 ⇒ 该调用必被拒)
var missing []string
for a := range called {
if !declared[a] {
missing = append(missing, a)
}
}
if len(missing) > 0 {
sort.Strings(missing)
t.Fatalf("插件调用了未声明的内核动作 %v\n"+
"(运维按 declared 授予 ⇒ 这些调用会被拒;remove_source 漏声明时"+
"的表现是「账号删了、源还在对外服务」,且因 AddSource 是 upsert 而静默)",
missing)
}
t.Logf("✓ 声明覆盖全部调用:declared=%d called=%d", len(declared), len(called))
}

View File

@ -0,0 +1,251 @@
package core
import (
"testing"
"llmsproxy/internal/lua"
)
// ★ account_reorder 必须**真的改变内核里的源顺序**。
//
// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的
// **源注册顺序** → 内核按 candidates 顺序依次尝试。
// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新
// 顺序 remove+add 一遍。
//
// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁),
// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。
func TestAccountReorderActuallyReordersKernelSources(t *testing.T) {
h := newReorderHarness(t)
h.addAccount("acct-a")
h.addAccount("acct-b")
// 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。
// ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**,
// 否则"重排生效"与"本来就是这个顺序"无法区分 ——
// 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a],
// 去掉重注册的变异**照样通过**(判据没咬住)。
before := h.sourceOrder()
if len(before) < 2 {
t.Fatalf("应有 2 个账号源,实得 %v", before)
}
if before[0] != "channels-buddy-acct-a" {
t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+
"若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before)
}
// 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)——
// 目标顺序与初始相反,故只有真的重注册才能达成。
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
after := h.sourceOrder()
if len(after) < 2 {
t.Fatalf("重排后源丢了:%v", after)
}
idxOf := func(list []string, id string) int {
for i, n := range list {
if n == id {
return i
}
}
return -1
}
b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a")
if b < 0 || a < 0 {
t.Fatalf("重排后找不到账号源:%v", after)
}
if b > a {
t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+
"实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+
"快到期积分仍不会优先消耗)", after)
}
t.Logf("✓ 源顺序已按重排生效: %v", after)
}
// ── harness ──
type reorderHarness struct {
t *testing.T
ps *lua.Plugins
}
func newReorderHarness(t *testing.T) *reorderHarness {
t.Helper()
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)
}
// ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝
// ("kernel action add_source is not granted"),源一个都不会出现 ——
// 我第一版漏了它,于是"应有 2 个源,实得 []"。
_ = ps.SetPluginKernelPermission("channels",
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
return &reorderHarness{t: t, ps: ps}
}
func (h *reorderHarness) addAccount(id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", id, err)
}
}
// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。
//
// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是
// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信
// (实测表现:顺序忽而两个忽而一个)。
func (h *reorderHarness) sourceOrder() []string {
h.t.Helper()
ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
h.t.Fatalf("__list_sources_probe: %v", err)
}
j := jsonOf(h.t, ls)
var out []string
arr, _ := j["sources"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
n, _ := m["name"].(string)
if len(n) > 15 && n[:15] == "channels-buddy-" {
out = append(out, n)
}
}
return out
}
// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。
//
// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等,
// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。
// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。
//
// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**,
// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分),
// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。
func TestPoolListOrderIsDeterministic(t *testing.T) {
// ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。
// `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到
// 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序,
// 与"排序是否确定"无关(去掉兜底键的变异照样通过)。
//
// 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后,
// 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。
h := newReorderHarness(t)
for _, id := range []string{"acct-c", "acct-b", "acct-a"} {
h.addAccount(id)
}
// 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减)
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"},
}); err != nil {
t.Fatal(err)
}
// priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效**
order := h.sourceOrder()
want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a"
if got := joinStrings(order, ","); got != want {
t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want)
}
// ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同),
// 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。
h.clearPriorities()
if !sameSecond(h.createdAts()) {
t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts())
}
if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{
"platform": "buddy",
"ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"},
}); err != nil {
t.Fatal(err)
}
h.clearPriorities()
h.resync("buddy")
order2 := h.sourceOrder()
// 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序
want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c"
if got := joinStrings(order2, ","); got != want2 {
t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+
"(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)",
got, want2)
}
t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2)
}
func joinStrings(xs []string, sep string) string {
out := ""
for i, x := range xs {
if i > 0 {
out += sep
}
out += x
}
return out
}
// createdAts 取当前 pool 里 buddy 账号的 createdAt。
func (h *reorderHarness) createdAts() []int64 {
h.t.Helper()
res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"})
if err != nil {
h.t.Fatal(err)
}
var out []int64
for _, a := range accountsOf(h.t, res) {
if v, ok := a["createdAt"].(float64); ok {
out = append(out, int64(v))
}
}
return out
}
// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。
func sameSecond(ts []int64) bool {
if len(ts) < 2 {
return false
}
for _, v := range ts {
if v != ts[0] {
return false
}
}
return true
}
// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。
func (h *reorderHarness) clearPriorities() {
h.t.Helper()
if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{
"platform": "buddy",
}); err != nil {
h.t.Fatalf("__clear_priorities_probe: %v", err)
}
}
// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。
func (h *reorderHarness) resync(platform string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{
"platform": platform,
}); err != nil {
h.t.Fatalf("__resync_sources_probe: %v", err)
}
}

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

@ -27,8 +27,12 @@ func TestUpstreamGapsAreDocumented(t *testing.T) {
s := string(src)
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH
if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") {
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH。
// ⚠ 断言 **URL 的两半**而不是那一整串字面量:本仓已把"路径从 base_url
// 移到 endpoint"(内核 URL = base_url + endpoint)—— 继续钉死整串会让
// 一个纯粹的形态重构把这条**语义**判据弄红,那是判据在咬自己。
if !strings.Contains(s, `base_url = "https://api2-v2.qoder.sh"`) ||
!strings.Contains(s, `endpoint = "/model/v1/chat/completions"`) {
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
"否则响应会被当成畸形数据")

View File

@ -0,0 +1,38 @@
package core
import (
"testing"
"unicode/utf8"
)
// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。
func TestUtf8ClipBehaviour(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)
}
s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表"
for _, n := range []int{10, 40, 119, 120, 121, 400} {
out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n})
if err != nil {
t.Fatal(err)
}
got, _ := jsonOf(t, out)["out"].(string)
if !utf8.ValidString(got) {
t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got)
}
if n < len(s) && len(got) > n {
t.Errorf("n=%d 结果超出字节上限: %d", n, len(got))
}
if n >= len(s) && got != s {
t.Errorf("n=%d 未超长时应原样返回", n)
}
t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got))
}
}

View File

@ -252,14 +252,16 @@ func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
})
}
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
// ★ 基座(anthropic.lua)读不到时必须在**安装期响亮失败**,不得静默退化。
//
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
//
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
// transform_request 必须返回错误而不是原样透传。
// ⚠ 判据随设计更新(v2.0.37):基座**不再由适配器自己读**(适配器沙箱没有
//
// fs,那样必然失败且静默降级)。现在由**插件主体**读好并内联 ⇒ 基座不可得
// 时应在**安装期**就失败(返回 err),而不是等到运行期才发现。
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
dir := t.TempDir()
@ -278,21 +280,26 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
vm := c.VM()
if vm == nil {
t.Fatal("VM 不可用")
// 用空目录重新安装:基座不可得 ⇒ 必须**安装期**失败且原因点明 anthropic
res, err := ps.Call("channels", "__adapter_install_probe", map[string]interface{}{
"adapters_dir": filepath.Join(dir, "adapters-no-base"),
})
if err != nil {
t.Fatal(err)
}
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
// 但调用必须报错
out, err := vm.Transform("channels-zcode", "transform_request",
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err == nil {
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc, ok := j["zcode_reinstall"].(map[string]interface{})
if !ok {
t.Fatalf("安装探针应回报 zcode_reinstall 结果,实得 %v", j)
}
if !strings.Contains(err.Error(), "基座") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
if zc["ok"] == true {
t.Fatalf("基座不可得时安装必须失败(否则运行期才炸、且是逐帧炸),实得 %v", zc)
}
t.Logf("✓ 基座缺失时响亮失败: %v", err)
msg, _ := zc["err"].(string)
if !strings.Contains(msg, "anthropic") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %q", msg)
}
t.Logf("✓ 基座缺失时安装期响亮失败: %s", msg)
}
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。

View File

@ -11,7 +11,6 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
@ -150,9 +149,6 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
if err := c.seedKeys(); err != nil {
return nil, err
}
if err := c.seedAuto(); err != nil {
return nil, err
}
if err := c.rebuildRegistry(); err != nil {
return nil, err
}
@ -195,39 +191,6 @@ func (c *Core) migrateFromRuntime() {
}
}
// seedAuto uses the existing auto rules from config.yaml, or creates an
// initial chain from legacy per-source model priority (first run only).
func (c *Core) seedAuto() error {
if len(c.cfg.Auto) > 0 {
return nil
}
type item struct {
model string
prio int
}
var flat []item
for _, s := range c.mergedSources() {
for _, m := range s.Models {
if m.Kind == "image" {
continue
}
flat = append(flat, item{m.ID, m.Priority})
}
}
sort.SliceStable(flat, func(i, j int) bool {
if flat[i].prio != flat[j].prio {
return flat[i].prio > flat[j].prio
}
return flat[i].model < flat[j].model
})
entries := make([]config.ModelScope, 0, len(flat))
for _, it := range flat {
entries = append(entries, config.ModelScope{Model: it.model})
}
c.cfg.Auto = entries
return c.cfg.Save()
}
// seedKeys ensures config.yaml has admin keys for every gateway_keys entry
// from the YAML config (once). After first save they become GWKey records
// in c.cfg.Keys and gateway_keys is no longer read for auth.
@ -877,9 +840,23 @@ func (c *Core) Reload() error {
// ---- adapter management (web UI) ----
func (c *Core) ListAdapters() []lua.APIAdapter {
c.syncHiddenRefs()
return c.vm.ListAdapters()
}
// syncHiddenRefs 把"当前被源引用的适配器"同步给 VM。被引用的 hidden=true 适配器
// 必须保持可见(源编辑器要回显当前值);没被引用的隐藏适配器从列表消失。
// 每次 ListAdapters 前调用:源集合是动态的(运行时可增删),推模型容易漏。
func (c *Core) syncHiddenRefs() {
refs := []string{}
for _, s := range c.mergedSources() {
if s.Adapter != "" {
refs = append(refs, s.Adapter)
}
}
c.vm.SetHiddenRefs(refs)
}
func (c *Core) UploadAdapter(name, code string) error {
if name == "" {
return fmt.Errorf("adapter name required")

View File

@ -22,7 +22,7 @@ func newTestConfig(t *testing.T) *config.Config {
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-gw-test"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}}},
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}}},
},
}
}

View File

@ -151,7 +151,7 @@ func TestAutoChainForConcurrentWithSourceChanges(t *testing.T) {
name := fmt.Sprintf("churn%d", n)
_ = c.AddSource(config.Source{
Name: name, BaseURL: "http://127.0.0.1:9/v1", Adapter: "openai",
Models: []config.Model{{ID: "m-" + name, Priority: 10}}},
Models: []config.Model{{ID: "m-" + name}}},
)
_ = c.RemoveSource(name)
}

View File

@ -32,7 +32,7 @@ func perKeyTestConfig(t *testing.T) *config.Config {
GatewayKeys: []string{"sk-gw-admin"},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
},
// Global chain points at gpt-4o.
Auto: []config.ModelScope{{Model: "gpt-4o", Source: "s1", Tier: 1}},
@ -198,7 +198,7 @@ func TestKeyAutoChainsRebuildOnSourceChange(t *testing.T) {
// Add the source; the per-key chain must pick it up without a restart.
cfg.Sources = append(cfg.Sources, config.Source{
Name: "s2", BaseURL: "http://127.0.0.1:2/v1", Adapter: "openai",
Models: []config.Model{{ID: "new-model", Priority: 10}},
Models: []config.Model{{ID: "new-model"}},
})
if err := c.Reload(); err != nil {
t.Fatalf("Reload: %v", err)

View File

@ -42,7 +42,7 @@ func writeSealedInstall(t *testing.T, dir string) string {
GatewayKeys: []string{"sk-gw-seed-me"},
Sources: []config.Source{{
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
}},
}
c, err := NewFromConfig(cfg)
@ -76,7 +76,7 @@ func writeSealedInstallNoSeed(t *testing.T, dir string) string {
DefaultModel: "AUTO",
Sources: []config.Source{{
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o"}},
}},
}
c, err := NewFromConfig(cfg)

View File

@ -535,11 +535,11 @@ type modelCaps struct {
}
type upstreamModel struct {
ID string `json:"id"`
Object string `json:"object"`
Kind string `json:"kind"`
Type string `json:"type"`
Modality string `json:"modality"`
ID string `json:"id"`
Object string `json:"object"`
Kind string `json:"kind"`
Type string `json:"type"`
Modality string `json:"modality"`
Architecture struct {
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
@ -699,8 +699,8 @@ func modelMeta(m upstreamModel) map[string]interface{} {
}
}
copyFields(m.Metadata) // nested first
copyFields(mustJSON(m)) // top level wins
copyFields(m.Metadata) // nested first
copyFields(mustJSON(m)) // top level wins
if len(meta) == 0 {
return nil
}
@ -740,10 +740,10 @@ func mustJSON(v interface{}) []byte {
func modelsToConfig(chat, image []string) []config.Model {
out := make([]config.Model, 0, len(chat)+len(image))
for _, id := range chat {
out = append(out, config.Model{ID: id, Priority: 50, Kind: "chat"})
out = append(out, config.Model{ID: id, Kind: "chat"})
}
for _, id := range image {
out = append(out, config.Model{ID: id, Priority: 70, Kind: "image"})
out = append(out, config.Model{ID: id, Kind: "image"})
}
return out
}

View File

@ -61,6 +61,20 @@ type discoverRequest struct {
// a typo here would produce a source that 404s on every request.
Include []string `json:"include,omitempty"`
Exclude []string `json:"exclude,omitempty"`
// ImageModels names ids that are **kind=image** but whose upstream catalog
// does not say so.
//
// 为什么需要(真实缺口):discoveredModelKind 只看上游的 modality 字段,
// 并且刻意**不从模型名推断**(那会把 "gpt-image-1" 这类同名聊天模型
// 错归成生图,而错误的 kind 会毒死 AUTO 链)。
// 但有些网关的 `/v1/models` 只返回 `{id,object,created,owned_by}` ——
// 零 modality(实测:某聚合网关的 3 个 gpt-image-* 全部落成 chat,
// 于是生图端点永远报 `no image source configured`)。
//
// 两条原则都不破:不猜(默认仍完全按元数据),但允许**显式声明**。
// 只影响写入的 kind,不影响目录里有没有这个模型。
ImageModels []string `json:"image_models,omitempty"`
}
// discoverResponse is the JSON the WebUI binds to.
@ -117,8 +131,36 @@ func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request
return
}
req.BaseURL = strings.TrimSpace(req.BaseURL)
req.Name = strings.TrimSpace(req.Name)
// ★ 只给 `name`(或两者都给但 key 为空)⇒ **从已存源取 base_url 与凭据**。
//
// 为什么必须有这条路径(真实痛点):刷新一个**已存在**源的模型目录,此前
// 必须让运维把明文 key 再粘一次 —— 而库里的 key 是 `enc:v1:` 密文,
// 运维手里未必还有原文;WebUI 也不回显明文(这是正确的安全设计)。
// 结果就是「想刷新目录 → 必须重新申请/粘贴一次 key」,很多人干脆不刷新,
// 于是源的模型目录越放越旧(实例:某源的 3 个生图模型从未被登记)。
//
// 安全边界:① 已要求 admin 角色;② 只能探测**本网关已配置的**源,
// 不能借此拿任意 URL + 任意 key 做探测(那样就成了 SSRF/密钥外带渠道);
// ③ 响应不回显 key(只回目录)。
if req.Name != "" {
if old := g.sourceByName(req.Name); old != nil {
if req.BaseURL == "" {
req.BaseURL = old.BaseURL
}
if req.APIKey == "" {
req.APIKey = old.APIKey
}
if req.Adapter == "" {
req.Adapter = old.Adapter
}
}
}
if req.BaseURL == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "base_url required")
writeError(w, http.StatusBadRequest, "invalid_request",
"base_url required (or pass name of an existing source to reuse its base_url and credential)")
return
}
if req.Adapter == "" {
@ -144,6 +186,58 @@ func (g *Gateway) handleSourceDiscoverAPI(w http.ResponseWriter, r *http.Request
return
}
// ★ 显式生图声明:把 operator 点名的 id 从 chat 挪到 image。
//
// 放在 selectModels **之前**:后者按 include/exclude 裁剪两个列表,
// 挪类必须在裁剪前完成,否则一个"只声明成生图、又被 include 选中"的
// 模型会先在 chat 里被保留、再被这里挪走 —— 顺序反了会得到两份。
//
// 只认**目录里确实存在的** id:写错一个字不该静默造出一个不存在的模型
// (那会让源在每次照片请求时 404),而是明确报错。
if len(req.ImageModels) > 0 {
have := map[string]bool{}
for _, m := range res.Models {
have[m] = true
}
for _, m := range res.ImageModels {
have[m] = true
}
var missing []string
force := map[string]bool{}
for _, id := range req.ImageModels {
if !have[id] {
missing = append(missing, id)
continue
}
force[id] = true
}
if len(missing) > 0 {
writeError(w, http.StatusBadRequest, "invalid_request",
"image_models contains ids that are not in the upstream catalog: "+
strings.Join(missing, ", "))
return
}
var keepChat []string
for _, m := range res.Models {
if !force[m] {
keepChat = append(keepChat, m)
}
}
seenImg := map[string]bool{}
var newImage []string
for _, m := range res.ImageModels {
seenImg[m] = true
newImage = append(newImage, m)
}
for id := range force {
if !seenImg[id] {
newImage = append(newImage, id)
}
}
res.Models = keepChat
res.ImageModels = newImage
}
chat, image, warns, err := selectModels(res.Models, res.ImageModels, req.Include, req.Exclude)
if err != nil {
// 400: the operator asked for a model the catalog does not have.

View File

@ -497,9 +497,6 @@ func TestModelsToConfig_PrioritiesAndKinds(t *testing.T) {
if got[2].ID != "img1" || got[2].Kind != "image" {
t.Errorf("image model mis-sorted: %+v", got[2])
}
if got[2].Priority <= got[0].Priority {
t.Errorf("image model priority %d should sort after chat %d", got[2].Priority, got[0].Priority)
}
}
// TestDiscoverModels_ImageModelsNeverNull guards the UI contract end to end,

View File

@ -53,6 +53,42 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
if err := cfg.ApplyDefaults(); err != nil {
t.Fatal(err)
}
// ★ 测试默认建一条 AUTO 链(每个源一个 tier,按传入顺序),因为内核
// **不再** seedAuto 自动播种规则了(那条废弃逻辑已删除:AUTO 链为空时
// 网关应当直接 503,而不是凭空生成一堆用户没配过的档位)。
//
// 但既有测试大量依赖"AUTO 能跑",它们此前是隐式吃 seedAuto 的。这里显式
// 建链,既保持测试原意(验调度而不是验播种),也让"空链=503"成为
// 一个**可以单独断言**的行为(见 TestChatAutoEmptyChainIsRejected)。
//
// 顺序沿用 seedAuto 的老口径:**源按"最后一个最先"排**(tier 1 = 最后
// 传入的源)。既有用例靠这个语义挑"high"源(TestChatAUTO 期望 high-m),
// 改成正序会让一批用例因为"挑到了另一个源"而红,而那与本次重构无关。
//
// 所有源放在**同一个 tier**(tier 1),而不是一个源一个 tier:链内同一
// tier 的多个 slot 会互相 failover,这正是 TestChatAutoChainTierFailover
// 要验的行为("a 挂 500 → 请求由 b 服务")。一源一 tier 会让 a 独占 tier1,
// b 在 tier2 永远轮不到,"两个源都被打过"这条断言就必然失败。
if len(cfg.Auto) == 0 && len(srcs) > 0 {
// 一源一 tier,**倒序**:最后传入的源拿 tier 1(最先尝试)。
// TestChatAUTO(low 先传、high 后传,期望 high)靠的就是这个语义。
// 同 tier failover 由 TestChatAutoChainTierFailover 自己显式建链覆盖 ——
// 两条测试验的是不同机制(tier 优先 vs 同 tier 轮转),不能用同一个
// 默认夹具同时满足。
tier := 0
for i := len(srcs) - 1; i >= 0; i-- {
tier++
s := srcs[i]
for _, m := range s.Models {
if m.Kind == "image" {
continue // 图片模型不进 chat 链(与 buildAutoChain 同口径)
}
cfg.Auto = append(cfg.Auto, config.ModelScope{
Model: m.ID, Source: s.Name, Tier: tier,
})
}
}
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
@ -107,9 +143,18 @@ func TestChatAutoChainTierFailover(t *testing.T) {
defer aUp.Close()
defer bUp.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
)
// ★ 显式把两个源放进**同一 tier**:本用例验的是 tier 内失败轮转。
// 默认夹具是一源一 tier(供 TestChatAUTO 验优先级),两者语义不同,
// 必须在这里覆盖,否则 b 在 tier2 永远轮不到。
if err := g.core.SaveAutoRules([]config.ModelScope{
{Model: "a-m", Source: "a", Tier: 1},
{Model: "b-m", Source: "b", Tier: 1},
}); err != nil {
t.Fatalf("save auto rules: %v", err)
}
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
@ -134,8 +179,8 @@ func TestChatAutoChain503Summary(t *testing.T) {
defer aUp.Close()
defer bUp.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m", Priority: 10}}},
config.Source{Name: "a", BaseURL: aUp.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
config.Source{Name: "b", BaseURL: bUp.URL, Adapter: "openai", Models: []config.Model{{ID: "b-m"}}},
)
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
@ -156,7 +201,7 @@ func TestChatAutoQuotaSkip(t *testing.T) {
up := upstream(t, ctrl)
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
)
// one slot with an hourly quota of 1 token
rr := doReq(t, g, "PUT", "/api/auto",
@ -191,7 +236,7 @@ func TestAutoStatesReportChainHealth(t *testing.T) {
up := upstream(t, ctrl)
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
)
doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
@ -234,7 +279,7 @@ func TestAutoSaveResetsCooldown(t *testing.T) {
up := upstream(t, ctrl)
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m", Priority: 100}}},
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "a-m"}}},
)
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
@ -348,8 +393,8 @@ func TestChatAUTO(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m", Priority: 10}}},
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m", Priority: 100}}},
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m"}}},
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m"}}},
)
// no model -> AUTO -> picks the highest priority source
rr := doReq(t, g, "POST", "/v1/chat/completions",
@ -428,8 +473,8 @@ func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
}))
defer chatUp.Close()
g := newTestGateway(t,
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m", Priority: 100}}},
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image", Priority: 1}}},
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m"}}},
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image"}}},
)
rr := doReq(t, g, "POST", "/v1/images/generations",
`{"model":"AUTO","prompt":"a cat"}`)

View File

@ -44,7 +44,7 @@ func keyAutoGateway(t *testing.T) *Gateway {
},
Sources: []config.Source{
{Name: "s1", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
Models: []config.Model{{ID: "gpt-4o", Priority: 10}, {ID: "gpt-4o-mini", Priority: 5}}},
Models: []config.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}}},
},
}
if err := cfg.ApplyDefaults(); err != nil {

View File

@ -36,7 +36,7 @@ func quotaGateway(t *testing.T, keyA, keyB config.GWKey) (*Gateway, *upstreamCtr
Keys: []config.GWKey{keyA, keyB},
Sources: []config.Source{{
Name: "up", BaseURL: up.URL, Adapter: "openai",
Models: []config.Model{{ID: "m1", Priority: 100}},
Models: []config.Model{{ID: "m1"}},
}},
}
if err := cfg.ApplyDefaults(); err != nil {
@ -306,14 +306,24 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway {
Sources: []config.Source{{
Name: "up", BaseURL: upURL, Adapter: "openai",
Models: []config.Model{
{ID: "m1", Priority: 100},
{ID: "m2", Priority: 90},
{ID: "m1"},
{ID: "m2"},
},
}},
}
if err := cfg.ApplyDefaults(); err != nil {
t.Fatal(err)
}
// 内核不再 seedAuto 自动播种 AUTO 链(空链=503 是刻意的)。本夹具的用例
// 全都在验"AUTO 请求会不会被 key 配额挡住",必须显式建一条链 ——
// 否则每个用例都先撞 503,验的就不是配额了。
if len(cfg.Auto) == 0 {
cfg.Auto = []config.ModelScope{
{Model: "m1", Source: "up", Tier: 1},
{Model: "m2", Source: "up", Tier: 2},
}
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
@ -355,8 +365,8 @@ func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) {
Sources: []config.Source{{
Name: "up", BaseURL: up.URL, Adapter: "openai",
Models: []config.Model{
{ID: "m1", Priority: 100},
{ID: "m2", Priority: 90},
{ID: "m1"},
{ID: "m2"},
},
}},
}

View File

@ -106,7 +106,7 @@ func messagesGateway(t *testing.T) (*httptest.Server, string) {
Sources: []config.Source{{
Name: "anth", BaseURL: up.URL, Adapter: "anthropic",
APIKey: "k", MaxConcurrent: 4,
Models: []config.Model{{ID: "claude-test", Kind: "chat", Priority: 100}},
Models: []config.Model{{ID: "claude-test", Kind: "chat"}},
}},
}

View File

@ -71,11 +71,6 @@ func TestSourcePutUpdatesSource(t *testing.T) {
continue
}
found = true
for _, m := range s.Models {
if m.ID == "m-one" && m.Priority != 42 {
t.Fatalf("priority not applied: %+v", s.Models)
}
}
}
if !found {
t.Fatal("source vanished after PUT")

View File

@ -101,7 +101,7 @@ func TestStreamedChatRecordsUpstreamUsage(t *testing.T) {
defer up.Close()
g := newTestGateway(t, config.Source{
Name: "up", BaseURL: up.URL, Adapter: "openai",
Models: []config.Model{{ID: "m1", Priority: 100}},
Models: []config.Model{{ID: "m1"}},
})
rr := doReq(t, g, "POST", "/v1/chat/completions",
@ -127,7 +127,7 @@ func TestStreamedAndNonStreamedAgreeOnTokens(t *testing.T) {
defer up.Close()
g := newTestGateway(t, config.Source{
Name: "up", BaseURL: up.URL, Adapter: "openai",
Models: []config.Model{{ID: "m1", Priority: 100}},
Models: []config.Model{{ID: "m1"}},
})
if rr := doReq(t, g, "POST", "/v1/chat/completions",
@ -167,7 +167,7 @@ func TestStreamedChatFallsBackToEstimateWithoutUsage(t *testing.T) {
defer up.Close()
g := newTestGateway(t, config.Source{
Name: "up", BaseURL: up.URL, Adapter: "openai",
Models: []config.Model{{ID: "m1", Priority: 100}},
Models: []config.Model{{ID: "m1"}},
})
if rr := doReq(t, g, "POST", "/v1/chat/completions",
@ -189,7 +189,7 @@ func TestImageGenerationDoesNotRecordTokens(t *testing.T) {
defer up.Close()
src := config.Source{
Name: "up", BaseURL: up.URL, Adapter: "openai",
Models: []config.Model{{ID: "img-m", Priority: 100, Kind: "image"}},
Models: []config.Model{{ID: "img-m", Kind: "image"}},
}
g := newTestGateway(t, src)

View File

@ -365,10 +365,11 @@
.ctx-itm .ic{width:15px;text-align:center}
/* ---- chat page ---- */
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px);min-height:0}
.chat-mode-bar{display:flex;align-items:center;gap:8px;padding:0 0 10px;flex:0 0 auto}
.chat-pane{flex:1;display:flex;flex-direction:column;min-height:0}
.tab-image{max-width:840px;margin:0 auto;display:flex;flex-direction:column;
height:calc(100vh - 190px)}
.tab-image{display:flex;flex-direction:column;flex:1;min-height:0}
.img-wrap{flex:1;display:flex;flex-direction:column;min-height:0}
.img-grid{flex:1;overflow-y:auto;padding:12px 2px;display:grid;
grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;align-content:start}
@ -589,21 +590,6 @@
/></svg
><span data-i="navChat">对话</span>
</button>
<button class="sb-i" data-tab="image">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 5h18v14H3z" />
<path d="m3 15 5-5 4 4 3-3 6 6" />
<circle cx="8.5" cy="8.5" r="1.5" />
></svg
><span data-i="navImage">生图</span>
</button>
<button class="sb-i" data-tab="keys">
<svg
viewBox="0 0 24 24"
@ -822,8 +808,7 @@
<main id="main">
<div id="tab-status" class="tab-pane"></div>
<div id="tab-chat" class="tab-pane hidden"></div>
<div id="tab-image" class="tab-pane hidden"></div>
<div id="tab-keys" class="tab-pane hidden"></div>
<div id="tab-keys" class="tab-pane hidden"></div>
<div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" class="tab-pane hidden"></div>
<div id="tab-adapters" class="tab-pane hidden"></div>
@ -1589,7 +1574,7 @@
// keep in sync, and adding a tab meant finding all three. A plugin page
// that is routed but never shown is exactly the kind of silent gap that
// survives review.
const TABS = ["status", "chat", "image", "keys", "sort", "sources", "adapters", "plugins"];
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
// Declared HERE, with var, because goTab() below reads it. It used to be a
// `const` further down the file next to the other plugin state, which put
// goTab's read inside the temporal dead zone: the first click on a plugin
@ -2898,6 +2883,13 @@ async function renderChat() {
: "");
$("#tab-chat").innerHTML = `
<div class="tab-chat">
<div class="chat-mode-bar">
<div class="kind-toggle">
<button data-mode="chat" class="small" onclick="switchChatMode('chat')">对话</button>
<button data-mode="image" class="ghost small" onclick="switchChatMode('image')">生图</button>
</div>
</div>
<div class="chat-pane" id="ct-chat-pane">
<div class="chat-wrap">
<div class="chat-log" id="ct-log">
<div class="chat-empty" id="ct-empty">
@ -2925,6 +2917,8 @@ async function renderChat() {
</div>
</div>
</div>
</div>
<div class="chat-pane" id="ct-image-pane" style="display:none"></div>
</div>`;
chatHistory = [];
chatImages = [];
@ -2939,22 +2933,22 @@ async function renderChat() {
$("#ct-imgs").style.display = "none";
$("#ct-msg").focus();
}
/* ---------- image tab ---------- */
// Image generation lives in its own page rather than as an option in the
// chat model dropdown.
//
// The dropdown used to list image models beside chat ones because
// /api/status flattened the catalog to []string and dropped the kind.
// Picking one sent the request to /api/chat, which cannot serve it. A
// second tab makes the distinction structural: the only models offered
// here are kind=image, and the endpoint called is the image one.
/* ---------- chat/image modes ---------- */
let imgBusy = false;
function imgModels() {
const out = [];
document.querySelectorAll("#it-model option").forEach((o) => {
if (o.value) out.push(o.value);
let chatMode = "chat";
function switchChatMode(mode) {
chatMode = mode === "image" ? "image" : "chat";
const chatPane = $("#ct-chat-pane");
const imagePane = $("#ct-image-pane");
if (chatPane) chatPane.style.display = chatMode === "chat" ? "" : "none";
if (imagePane) imagePane.style.display = chatMode === "image" ? "" : "none";
document.querySelectorAll("#tab-chat .chat-mode-bar button").forEach((button) => {
button.classList.toggle("ghost", button.dataset.mode !== chatMode);
});
return out;
if (chatMode === "image" && imagePane && !imagePane.dataset.rendered) {
imagePane.dataset.rendered = "1";
renderImage();
}
}
async function renderImage() {
const s = await api("/api/status");
@ -2977,7 +2971,7 @@ async function renderChat() {
const body = opts.length
? ""
: `<div class="empty">${esc(t("imgNoModels"))}</div>`;
$("#tab-image").innerHTML = `
$("#ct-image-pane").innerHTML = `
<div class="tab-image">
<div class="img-wrap">
<div class="img-grid" id="it-grid">
@ -6267,7 +6261,6 @@ toast(t("kAutoChainGlobal"));
const done = () => remountPluginElements();
if (tab === "status") return renderStatus().then(done, done);
if (tab === "chat") return renderChat().then(done, done);
if (tab === "image") return renderImage().then(done, done);
if (tab === "keys") return renderKeys().then(done, done);
if (tab === "sort") return renderSort().then(done, done);
if (tab === "sources") return renderSources().then(done, done);

View File

@ -42,28 +42,47 @@ func TestUI_ChatPageGroupsImageModels(t *testing.T) {
}
}
// TestUI_HasDedicatedImagePage pins the second half of the fix: image models
// need somewhere they CAN be tested.
func TestUI_HasDedicatedImagePage(t *testing.T) {
// TestUI_ImageModeIsReachableFromChatTab 钉住生图**仍可达**。
//
// ★ 断言已随 UI 结构调整:生图不再是独立侧栏项,而是对话页里的一个分段模式。
// 原先这条测试钉的是 `data-tab="image"` / `id="tab-image"` / `tab === "image"`
// —— 三者都已随合并移除。若继续钉旧结构,它只会红着挡住重构,而检不出
// "生图入口彻底消失"这种真回归。所以改为钉**合并后的可达性**:
// 分段控件在、切换函数在、面板容器在、端点仍在。
//
// 为什么"可达性"才是要守的东西:合并的动机是少一个导航项,而不是把生图功能
// 藏起来。只要用户能从对话页一键切到生图并真的发出请求,这个功能就是完整的。
func TestUI_ImageModeIsReachableFromChatTab(t *testing.T) {
ui := uiSource(t)
for _, want := range []string{
`data-tab="image"`, // sidebar entry
`id="tab-image"`, // pane
`function renderImage`, // renderer registered with refresh()
`tab === "image"`, // dispatched
`/v1/images/generations`, // the endpoint the page actually calls
`class="chat-mode-bar"`, // 分段栏存在
`switchChatMode('image')`, // 能切到生图
`id="ct-image-pane"`, // 生图面板容器
`function renderImage`, // 渲染器还在
`/v1/images/generations`, // 真正被调用的端点
} {
if !strings.Contains(ui, want) {
t.Errorf("image page is missing %q", want)
t.Errorf("生图模式不可达,缺少 %q", want)
}
}
// The page must be reachable from TABS; goTab iterates TABS to toggle pane
// visibility, so a pane outside that list is present but permanently hidden —
// which reads as "the page is blank" because the content is in the DOM.
if !strings.Contains(ui, `"image"`) {
t.Error(`"image" does not appear in the TABS list`)
// 合并后**不应**再出现独立 tab 的三件套 —— 它们会让 goTab 去找一个不存在的
// 面板($("#tab-image") 为 null ⇒ 点侧栏抛错)。
for _, gone := range []string{
`data-tab="image"`,
`id="tab-image"`,
`tab === "image"`,
} {
if strings.Contains(ui, gone) {
t.Errorf("生图已并入对话页,不应再有独立 tab 结构 %q"+
"(否则 goTab 会找一个不存在的面板)", gone)
}
}
// 面板必须在 DOM 里且**不能被初始隐藏到底**:切换函数用 display 控制。
if !strings.Contains(ui, `ct-chat-pane`) {
t.Error("缺少对话面板容器 ct-chat-pane(切换需要两个容器同时存在)")
}
}

View File

@ -0,0 +1,116 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// loadBuddyAdapter 装载内置 buddy 适配器。
func loadBuddyAdapter(t *testing.T) *VM {
t.Helper()
src, err := bundledAdapters.ReadFile("adapters/buddy.lua")
if err != nil {
t.Fatal(err)
}
vm := NewVM(t.TempDir())
t.Cleanup(vm.Stop)
if err := vm.LoadAdapterSource("buddy", string(src)); err != nil {
t.Fatalf("装载失败: %v", err)
}
return vm
}
// ★ buddy 适配器:两个厂商要求,各有实测依据。
//
// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150)
// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0)
func TestBuddyAdapterEffortAndHeaders(t *testing.T) {
vm := loadBuddyAdapter(t)
// ① glm-5.1 → 补 medium
out, err := vm.Transform("buddy", "transform_request",
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
if err != nil {
t.Fatal(err)
}
var b map[string]interface{}
if err := json.Unmarshal([]byte(out), &b); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if b["reasoning_effort"] != "medium" {
t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"])
}
// ② 其它模型**不补**(避免把单模型要求扩成全平台行为)
out2, err := vm.Transform("buddy", "transform_request",
`{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
if err != nil {
t.Fatal(err)
}
var b2 map[string]interface{}
json.Unmarshal([]byte(out2), &b2)
if b2["reasoning_effort"] != nil {
t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"])
}
// ③ 调用方已给档位时**尊重它**
out3, _ := vm.Transform("buddy", "transform_request",
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`)
var b3 map[string]interface{}
json.Unmarshal([]byte(out3), &b3)
if b3["reasoning_effort"] != "high" {
t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"])
}
// ④ 头:Accept 必须是 SSE
h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"})
if err != nil {
t.Fatal(err)
}
if h["Accept"] != "text/event-stream" {
t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"])
}
for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} {
if h[k] == "" {
t.Errorf("缺少 buddy 身份头 %s", k)
}
}
t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"])
}
// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。
func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) {
vm := loadBuddyAdapter(t)
chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}`
out, err := vm.Transform("buddy", "transform_stream_chunk", chunk)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) {
t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out)
}
}
// ★ 回归护栏:适配器**必须**定义全部四个转换函数。
//
// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码",
// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统),
// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request,
// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文,
// 网关却记 completion_tokens=0、客户端看到**空回复**。
// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底
// "看起来正常" ⇒ 症状指向错误方向。
//
// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话"
//
// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。
func TestBuddyAdapterDefinesAllTransforms(t *testing.T) {
vm := loadBuddyAdapter(t)
for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} {
if !vm.HasFn("buddy", fn) {
t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn)
}
}
t.Log("✓ 四个转换函数齐备")
}

View File

@ -0,0 +1,112 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// loadZenVM 装载 opencodezen 适配器(照 adapter_opencodezen_test.go 的方式)。
func loadZenVM(t *testing.T) *VM {
t.Helper()
src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua")
if err != nil {
t.Fatal(err)
}
vm := NewVM(t.TempDir())
t.Cleanup(vm.Stop)
if err := vm.LoadAdapterSource("opencodezen", string(src)); err != nil {
t.Fatalf("加载失败: %v", err)
}
return vm
}
// ★ assistant 回合的 reasoning_content **必须回传**给 opencode 上游。
//
// 真实缺陷(生产实测,2026-10-16):
//
// provider localzen: api error 400: Upstream request failed: [invalid_request_error]
// The `reasoning_content` in the thinking mode must be passed back to the API.
//
// 根因:transform_request 里写了 `msg.reasoning_content = nil`,理由是
// "Zen 免费池不接受回传"。该假设**方向反了** —— 上游要的就是它。
//
// ★★ 这个缺陷极难归因,因为失败是**交替**出现的:
// 只有当上一轮**真的产出了 reasoning**(thinking 模型 + reasoning_effort 开启)
// 时,回放那段历史才会被拒;普通回合照常 200。审计里表现为成串 502 紧随一个
// 成功的长上下文请求(实测 prompt_tokens=139330)之后。
//
// 判据必须**咬住「被剥掉」这个具体行为**,而不是"请求成功" —— 后者在
// 非 thinking 回合下永远为真,测不出这个缺陷。
func TestOpenCodeZenKeepsAssistantReasoningContent(t *testing.T) {
vm := loadZenVM(t)
body := map[string]interface{}{
"model": "deepseek-v4.1-flash-free",
"stream": true,
"messages": []map[string]interface{}{
{"role": "user", "content": "1+1=?"},
{
"role": "assistant",
"content": "2",
// 这段正是 agent 回放历史时会带上的东西
"reasoning_content": "一加一等于二。",
},
{"role": "user", "content": "再加一"},
},
}
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
out, err := vm.Transform("opencodezen", "transform_request", string(raw))
if err != nil {
t.Fatal(err)
}
// 解析出站体,找 assistant 回合
var sent struct {
Messages []map[string]interface{} `json:"messages"`
}
if err := json.Unmarshal([]byte(out), &sent); err != nil {
t.Fatalf("出站体不是合法 JSON: %v\n%s", err, out)
}
var found bool
for _, m := range sent.Messages {
if m["role"] != "assistant" {
continue
}
found = true
rc, _ := m["reasoning_content"].(string)
if rc == "" {
t.Fatalf("assistant 回合的 reasoning_content 被剥掉了 ⇒ 上游会回 "+
"`The reasoning_content in the thinking mode must be passed back to the API`\n"+
"出站 messages: %s", out)
}
if rc != "一加一等于二。" {
t.Errorf("reasoning_content 被改写:%q", rc)
}
}
if !found {
t.Fatalf("没找到 assistant 回合(判据失效)\n%s", out)
}
t.Log("✓ assistant 的 reasoning_content 已保留")
}
// ★ 反向:**空的** reasoning_content 应被丢掉(不要把空串发给上游)。
// 与上一条配对 —— 缺了这条,"改成无条件保留"这类过度修正不会被发现。
func TestOpenCodeZenDropsEmptyReasoningContent(t *testing.T) {
vm := loadZenVM(t)
raw := `{"model":"m","stream":true,"messages":[
{"role":"assistant","content":"hi","reasoning_content":""}
]}`
out, err := vm.Transform("opencodezen", "transform_request", raw)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, `"reasoning_content":""`) {
t.Errorf("空的 reasoning_content 不该发给上游(多余字段,部分上游会挑),实得: %s", out)
}
t.Log("✓ 空 reasoning_content 已丢弃")
}

View File

@ -343,6 +343,39 @@ function __prelude.openai_error(status, body)
return resp.detail
end
-- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。
local msg = nil
if type(resp.error_msg) == "string" and resp.error_msg ~= "" then
msg = resp.error_msg
elseif type(resp.msg) == "string" and resp.msg ~= "" then
msg = resp.msg
end
-- 腾讯系(buddy/workbuddy)用 msg + displayMsg.{zh,en},且**没有** message。
-- 参考 buddy-adapter.ts:576 明确:本地化文案最可读,排在**最前**。
local localized = nil
if type(resp.displayMsg) == "table" then
local dm = resp.displayMsg
if type(dm.zh) == "string" and dm.zh ~= "" then localized = dm.zh
elseif type(dm.en) == "string" and dm.en ~= "" then localized = dm.en end
end
if msg then
local code = nil
if type(resp.error_code) == "string" and resp.error_code ~= "" then
code = resp.error_code
elseif resp.code ~= nil and type(resp.code) ~= "string" then
code = tostring(resp.code)
end
local detail = code and (code .. ": " .. msg) or msg
if localized then return localized .. " " .. detail end
return detail
end
if localized then return localized end
if type(resp.error_code) == "string" and resp.error_code ~= "" then
return resp.error_code
end
return nil
end

View File

@ -0,0 +1,99 @@
package lua
import (
"testing"
)
// ★ 上游错误体抽取必须覆盖**网关与腾讯系**形状 —— 这两类实测都漏过。
//
// 发现路径(2026-10-16 读生产日志):
//
// unhandled upstream error body (adapter "openai" lacks transform_error):
// status=404 body={"error_msg":"404 Route Not Found"}
// unhandled upstream error body (adapter "buddy" lacks transform_error):
// status=400 body={"code":11101,"msg":"Non-stream chat request is not supported"}
//
// 两者都是 `{"error_msg":...}` / `{"msg":...}` —— 旧实现只认
// error/message/detail,于是返回 nil ⇒ 客户端只看到
// `api error 404: unknown error`,而 body 里明明有可用文案。
//
// ⚠️ 那两个适配器**都定义了** transform_error(openai.lua:139、buddy.lua:166),
// 内核却报 "lacks" ⇒ 一句会把排查带偏的诊断(已另行修正内核文案)。
func TestPreludeErrorExtractionCoversGatewayShapes(t *testing.T) {
vm := NewVM(t.TempDir())
defer vm.Stop()
// 用**仓库里的真实** openai 适配器(含共享前导),不是自造桩:
// 判据必须走真实实现,否则"补了形状"这件事根本没被验。
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
if err != nil {
t.Fatal(err)
}
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
t.Fatalf("装载 openai 适配器: %v", err)
}
cases := []struct {
name, body, want string
}{
{"APISIX error_msg",
`{"error_msg":"404 Route Not Found"}`, "404 Route Not Found"},
{"腾讯 msg(buddy 11101)",
`{"code":11101,"msg":"Non-stream chat request is currently not supported"}`,
"11101: Non-stream chat request is currently not supported"},
{"error_code + error_msg",
`{"error_code":"APIG.0301","error_msg":"verify ak sk signature fail"}`,
"APIG.0301: verify ak sk signature fail"},
{"腾讯 displayMsg 本地化(最可读,排最前)",
`{"code":11101,"msg":"Non-stream chat request is currently not supported",` +
`"displayMsg":{"en":"Invalid request. Please retry","zh":"请求内容不合法,请重试"}}`,
"请求内容不合法,请重试 11101: Non-stream chat request is currently not supported"},
{"仅 displayMsg",
`{"displayMsg":{"zh":"只有本地化文案"}}`, "只有本地化文案"},
{"仅 error_code",
`{"error_code":"InferHub.4291.200"}`, "InferHub.4291.200"},
// 回归:原有的 6 种形状不得丢
{"OpenAI 信封", `{"error":{"message":"boom"}}`, "boom"},
{"裸字符串 error", `{"error":"boom"}`, "boom"},
{"扁平 code/message", `{"message":"boom"}`, "boom"},
{"FastAPI detail", `{"detail":"boom"}`, "boom"},
{"HTML title", `<html><title>413 Too Large</title></html>`, "413 Too Large"},
{"裸文本", "plain failure line\nmore", "plain failure line"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok, err := vm.TransformError("openai", 400, c.body)
if err != nil {
t.Fatalf("TransformError: %v", err)
}
if !ok {
t.Fatalf("★ 抽不出消息(返回 ok=false)⇒ 客户端只会看到 "+
"'unknown error',而 body 里明明有:%s", c.body)
}
if got != c.want {
t.Fatalf("抽出的文案不对\n 实得: %q\n 期望: %q", got, c.want)
}
})
}
}
// 配对判据:真正**没有** transform_error 的适配器仍应返回 ok=false
// (不能为了让上面那条通过就把"抽不出"和"没这个钩子"混为一谈)。
func TestPreludeErrorExtractionStillReportsUnknownShapes(t *testing.T) {
vm := NewVM(t.TempDir())
defer vm.Stop()
src, err := bundledAdapters.ReadFile("adapters/openai.lua")
if err != nil {
t.Fatal(err)
}
if err := vm.LoadAdapterSource("openai", string(src)); err != nil {
t.Fatalf("装载 openai 适配器: %v", err)
}
// 一个确实认不出的 JSON(没有 message/error/detail/msg/error_msg)
got, ok, err := vm.TransformError("openai", 400, `{"weird":true}`)
if err != nil {
t.Fatalf("TransformError: %v", err)
}
if ok {
t.Fatalf("认不出的形状不该硬凑文案,实得 %q", got)
}
}

View File

@ -0,0 +1,97 @@
package lua
import (
"os"
"regexp"
"strings"
"testing"
)
// ★ zcode 适配器**源码模板**必须真的能把 anthropic 基座合并进来。
//
// 背景:此前适配器在**沙箱里自己读 anthropic.lua** ⇒ 必然失败(沙箱无 fs、
// 无 plugin_dir)且**静默降级** ⇒ 只有它自己定义的 2 个函数,缺 7 个
// ⇒ 每次调用内核报 "missing transform_response" ⇒ 渠道**完全不可用**。
// 该缺陷一直潜伏,因为 zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
//
// ⚠️ **本判据的边界(务必如实理解)**:它在测试里**自己**做占位符替换,
//
// 所以它证明的是「**模板形态**正确 + 合并逻辑能吃下基座」,
// 而**不能**证明「installZcodeAdapter 真的内联了非空基座」——
// 把安装处的 `asrc` 换成空串,本判据**不会**报红(已实测)。
// 安装接线由 internal/core 的端到端判据覆盖:
// TestZcodeAdapterInjectsOfficialIdentityBlocks
// TestZcodeAdapterKeepsAnthropicProtocolConversion(工具调用/图像块存活)
// TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(基座不可得 ⇒ 安装期报错)
// 这三条**会**咬住"内联空基座"这个变异(已实测)。
// ⇒ 教训:判据自己复现被测逻辑时,必须说清它到底覆盖了哪一段。
func TestZcodeAdapterInheritsAnthropicBase(t *testing.T) {
psrc, _ := os.ReadFile("plugins/channels.lua")
s := string(psrc)
diag := regexp.MustCompile(`(?s)local ZCODE_DIAG_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
ad := regexp.MustCompile(`(?s)local ZCODE_ADAPTER_SRC = \[===\[(.*?)\]===\]`).FindStringSubmatch(s)[1]
asrc, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
if err != nil {
t.Fatal(err)
}
full := "local Z = (function()\n" + diag + "\nend)()\n" + ad
// ⚠ 占位符不等于"适配器里能读到基座":真正要验的是**装载后 9 个函数都在**。
// 故这里按 installZcodeAdapter 的同一手法内联,再逐函数断言。
if strings.Contains(full, "__INLINE_ANTHROPIC_SRC__") {
full = strings.ReplaceAll(full, "__INLINE_ANTHROPIC_SRC__", quoteLua(string(asrc)))
} else {
t.Fatal("适配器源码必须有基座占位符(基座由插件主体内联;" +
"适配器沙箱里读不到文件)")
}
vm := NewVM(t.TempDir())
defer vm.Stop()
if err := vm.LoadAdapterSource("channels-zcode", full); err != nil {
t.Fatalf("装载失败: %v", err)
}
want := []string{"transform_request", "transform_response", "transform_stream_chunk",
"transform_error", "serialize_chunk", "serialize_close", "serialize_open_preamble",
"serialize_response", "deserialize_request"}
var lack []string
for _, f := range want {
if !vm.HasFn("channels-zcode", f) {
lack = append(lack, f)
}
}
if len(lack) > 0 {
t.Fatalf("★ zcode 仍缺 %d 个函数(%v)⇒ 基座继承失败 ⇒ "+
"每次调用都会报 'missing <fn>' ⇒ 渠道完全不可用", len(lack), lack)
}
// 行为验证:基座的 transform_response 必须真的能转换 anthropic 响应
out, err := vm.Transform("channels-zcode", "transform_response",
`{"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`)
if err != nil {
t.Fatalf("transform_response 调用失败: %v", err)
}
if !strings.Contains(out, "hi") {
t.Errorf("基座 transform_response 未产出内容: %s", out)
}
t.Logf("✓ zcode 继承基座成功(9/9 函数),transform_response → %s", out)
}
func quoteLua(s string) string {
var b strings.Builder
b.WriteByte('"')
for i := 0; i < len(s); i++ {
switch c := s[i]; c {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
default:
b.WriteByte(c)
}
}
b.WriteByte('"')
return b.String()
}

View File

@ -0,0 +1,292 @@
local adapter = {}
adapter.name = "buddy"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
-- to the Anthropic Messages API internally, so they inherit Anthropic's
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
-- is replayed to every other source, so one such id kills every Claude slot at
-- once and an AUTO request falls through all tiers.
--
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
-- matching tool_call_id are rewritten identically within one request. A
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
-- well-behaved traffic is byte-identical to before.
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
local TOOL_ID_MAX = 64
local function safe_tool_id(id)
if type(id) ~= "string" or id == "" then return id end
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
if clean == id and #clean <= TOOL_ID_MAX then
return clean
end
local digest = string.sub(sha256_hex(id), 1, 8)
local keep = TOOL_ID_MAX - #digest - 1
if #clean > keep then clean = string.sub(clean, 1, keep) end
return clean .. "_" .. digest
end
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
--
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
local NEED_EFFORT = {
["glm-5.1"] = "medium",
}
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- private one.
req.thinking = nil
req.extra_body = nil
-- ★★ buddy(腾讯 CodeBuddy)上游**只支持流式**:body.stream=false 会被
-- 400 拒绝(实测 2026-10-19):
-- 11101: Non-stream chat request is currently not supported
-- 与 Accept: text/event-stream 头无关(头早已在发),判据是 body。
-- 故强制 stream=true,非流式请求由内核的"客户端要非流式、上游回流式"
-- 路径消化(transform_response 需能聚合 SSE,见下)。
req.stream = true
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
-- 否则上游回 400:
-- {"code":11150,"msg":"the reasoning effort value is not supported
-- by the current model"}
-- 实测(2026-10-16,逐字对照实验):
-- glm-5.1 裸(无该字段) → 400 11150 ✗
-- glm-5.1 + reasoning_effort:medium → 200 ✓
-- glm-5.1 空串 → 400 11150 ✗
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
-- glm-5.1 **独有**的要求。
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
-- 所有模型。
if NEED_EFFORT[req.model or ""] and
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
req.reasoning_effort == nil then
req.reasoning_effort = NEED_EFFORT[req.model]
end
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
if msg.tool_call_id ~= nil then
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
end
if type(msg.tool_calls) == "table" then
for _, tc in ipairs(msg.tool_calls) do
if type(tc) == "table" and tc.id ~= nil then
tc.id = safe_tool_id(tc.id)
end
end
end
end
end
return json.encode(req)
end
-- aggregate_sse(raw):把上游 SSE(data: 行流)聚合成一个非流式 OpenAI 响应。
-- 强制 stream=true 后,内核的非流式路径会在这里收到完整 SSE 原文——必须聚合,
-- 否则 json.decode 失败原样透传,Go 侧 unmarshal 报 invalid character 'd'。
-- 聚合 content / reasoning_content / tool_calls(按 index 拼接分片)/
-- finish_reason / usage;无可解析帧时返回 nil(调用方回退原样)。
local function aggregate_sse(raw)
local content, reasoning = {}, {}
local finish, usage, id, model, created = nil, nil, nil, nil, nil
local tools_by_index = {}
local seen = false
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
if s:sub(1, 5) == "data:" then
local payload = s:sub(6):gsub("^%s+", "")
if payload ~= "" and payload ~= "[DONE]" then
local ok, d = pcall(json.decode, payload)
if ok and type(d) == "table" then
seen = true
id = id or d.id
model = model or d.model
created = created or d.created
if type(d.usage) == "table" then usage = d.usage end
local ch = d.choices and d.choices[1]
if type(ch) == "table" then
if type(ch.finish_reason) == "string" and ch.finish_reason ~= "" then
finish = ch.finish_reason
end
local dl = ch.delta
if type(dl) == "table" then
if type(dl.content) == "string" then content[#content + 1] = dl.content end
if type(dl.reasoning_content) == "string" then
reasoning[#reasoning + 1] = dl.reasoning_content
end
if type(dl.tool_calls) == "table" then
for _, tc in ipairs(dl.tool_calls) do
if type(tc) == "table" and tc.index ~= nil then
local slot = tools_by_index[tc.index]
if slot == nil then
slot = { id = tc.id, type = tc.type or "function",
["function"] = { name = "", arguments = "" } }
tools_by_index[tc.index] = slot
end
if tc.id then slot.id = tc.id end
local fn = tc["function"]
if type(fn) == "table" then
if fn.name then slot["function"].name = slot["function"].name .. fn.name end
if fn.arguments then slot["function"].arguments = slot["function"].arguments .. fn.arguments end
end
end
end
end
end
end
end
end
end
end
if not seen then return nil end
local msg = { role = "assistant", content = table.concat(content) }
local rc = table.concat(reasoning)
if rc ~= "" then msg.reasoning_content = rc end
local ordered = {}
for i = 0, 63 do
if tools_by_index[i] then ordered[#ordered + 1] = tools_by_index[i] end
end
if #ordered > 0 then msg.tool_calls = ordered end
local out = {
id = id or "", object = "chat.completion", created = created or 0,
model = model or "",
choices = { { index = 0, message = msg,
finish_reason = (finish and finish ~= "") and finish or "stop" } },
}
if usage then out.usage = usage end
local ok2, enc = pcall(json.encode, out)
if not ok2 then return nil end
return enc
end
function adapter.transform_response(raw_body)
-- 上游只会回流式(transform_request 强制 stream=true)。客户端要非流式时
-- 内核把完整 SSE 原文交到这里,先聚合再走统一转换。
if type(raw_body) == "string"
and (raw_body:match("^%s*data:") or raw_body:match("[\r\n]data:")) then
local aggregated = aggregate_sse(raw_body)
if aggregated ~= nil then raw_body = aggregated end
end
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
-- Do not serialize an empty Lua table as JSON `{}`. That shape is
-- neither an OpenAI tool-call array nor an omitted field, and several
-- agent clients reject it on the final chunk.
for _, tc in ipairs(delta.tool_calls) do
if type(tc) == "table" and tc.id ~= nil then
tc.id = safe_tool_id(tc.id)
end
end
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
--
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
-- "模型不回答",极易误判成模型坏了或额度不足。
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
--
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
-- 是产品码(codebuddy)。
function adapter.build_headers(meta)
local h = {
["Accept"] = "text/event-stream",
["Content-Type"] = "application/json",
["X-Domain"] = "copilot.tencent.com",
["X-Product"] = "SaaS",
["X-Product-Code"] = "codebuddy",
["User-Agent"] = "CodeBuddyIDE/1.106.1",
}
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
h["Authorization"] = "Bearer " .. meta.api_key
end
return h
end
return adapter

View File

@ -1,8 +1,13 @@
local adapter = {}
adapter.name = "opencodezen"
adapter.version = "1.0.0"
adapter.version = "1.1.0"
adapter.endpoint = "/chat/completions"
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
adapter.hidden = true
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
--
@ -11,8 +16,14 @@ adapter.endpoint = "/chat/completions"
--
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
-- OpenCode"。
-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于
-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** ——
-- 剥掉恰恰会引发
-- The `reasoning_content` in the thinking mode must be passed back to the API
-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」
-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。
-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
-- 否则 thinking 模式直接 400。
--
@ -93,7 +104,7 @@ local function now_ms(meta)
return t * 1000
end
local function rand_id(prefix, ms, kind)
local function rand_id(prefix, ms, kind, affinity)
local seg
if kind == "session" then
seg = (ID_K - ms * 4096) % (MASK48 + 1)
@ -101,7 +112,15 @@ local function rand_id(prefix, ms, kind)
seg = (ms * 16) % (MASK48 + 1)
end
-- %012x:12 位十六进制,不足补零
return prefix .. string.format("%012x", seg) .. rand_tail(14)
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
local tail
if type(affinity) == "string" and affinity ~= "" then
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
else
tail = rand_tail(14)
end
return prefix .. string.format("%012x", seg) .. tail
end
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
@ -114,6 +133,41 @@ end
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
-- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求**
-- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶——
-- 1) meta.client_session(pi 等真会话客户端自带,会话恒定)
-- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何
-- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定)
-- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致)
-- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。
local function conversation_fingerprint(body)
if type(body) ~= "string" or body == "" then return "" end
local ok, req = pcall(json.decode, body)
if not ok or type(req) ~= "table" then return "" end
for _, m in ipairs(req.messages or {}) do
if type(m) == "table" and m.role == "user" then
local c = m.content
if type(c) == "string" then
if c ~= "" then return c end
elseif type(c) == "table" then
for _, part in ipairs(c) do
if type(part) == "table" and part.type == "text" and part.text then
return part.text
end
end
end
return ""
end
end
return ""
end
local function session_affinity_key(meta)
local cs = meta and meta.client_session
if type(cs) == "string" and cs ~= "" then return cs end
return conversation_fingerprint(meta and meta.body)
end
local function session_bucket_ms(meta)
local ms = now_ms(meta)
return ms - (ms % 60000)
@ -123,7 +177,9 @@ function adapter.build_headers(meta)
local ms = now_ms(meta)
local bucket = session_bucket_ms(meta)
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
local sid = rand_id("ses_", bucket, "session")
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
return {
["User-Agent"] = adapter.headers["User-Agent"],
["x-opencode-client"] = "cli",
@ -219,26 +275,50 @@ function adapter.transform_request(raw_body)
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
msg.role = "system"
end
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
msg.reasoning_content = nil
-- ★ 必须**保留** reasoning_content(不再剥离)。
--
-- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池
-- 不接受回传」。**生产实测证伪**(2026-10-16):
-- provider localzen: api error 400: Upstream request failed:
-- [invalid_request_error] The `reasoning_content` in the thinking
-- mode must be passed back to the API.
-- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** ——
-- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、
-- reasoning_effort 开启、长上下文)时,回放该历史才会被拒;
-- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。
-- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的
-- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。
--
-- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器
-- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
-- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
-- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。
--
-- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠
-- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」
-- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),
-- 需要时缺了就是硬 400。
if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then
msg.reasoning_content = nil
end
local drop = false
if type(msg.content) == "table" then
local parts = {}
for _, part in ipairs(msg.content) do
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
-- multimodal part not supported by zen
-- multimodal part not supported by zen
else
table.insert(parts, part)
end
end
if #parts == 0 then
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
or msg.tool_call_id ~= nil then
msg.content = ""

View File

@ -253,7 +253,7 @@ function adapter.transform_stream_chunk(raw_chunk)
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)

View File

@ -0,0 +1,117 @@
package lua
import (
"os"
"regexp"
"strings"
"testing"
)
// 侧边栏图标判据 —— 覆盖**所有**带页面的插件,不只是 billing。
//
// 为什么需要这个测试
// ------------------
// billing 当年声明了 icon = "",WebUI 把空串原样塞进按钮 ⇒ 侧边栏那条是空白。
// 当时只给 billing 加了判据(TestPluginIconIsNotARawEmoji)。后来 channels
// 声明 icon = ""、quota 连 icon 字段都没有 ⇒ 同样的"空白条目"缺陷又一次出现,
// 而判据只管 billing,所以全绿。
//
// 这就是"判据只覆盖出错的那一个文件"的代价:同类缺陷换个文件复发时没有任何
// 东西会响。本文件把范围改成**按页面遍历**,新增带页面的插件自动纳入。
// pageIcons 返回 插件文件 -> 其 page.icon 的声明值。
//
// 只认 page.icon(侧边栏读的就是它),不认顶层的其他 icon 字段 —— 那些不影响
// 侧边栏渲染,混进来会给出假绿。
func pageIcons(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
src := string(b)
// 短字符串:icon = "..."
shortRe := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`)
// 长字符串:icon = [==[ ... ]==](inline SVG 没法写成短字符串而不转义所有引号)
longRe := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`)
var out []string
for _, m := range shortRe.FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
for _, m := range longRe.FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
return out
}
// 带页面(plugin.ui.page)的插件。新增页面插件要登记在这里 ——
// 这是一份"必须被覆盖的清单",漏登记会让新插件逃避判据。
var pagePlugins = []string{
"plugins/billing.lua",
"plugins/channels.lua",
"plugins/quota.lua",
}
// TestEveryPagePluginDeclaresSVGIcon 钉住"每个带页面的插件都有 inline SVG 图标"。
//
// 两个必查项,各自对应一种真实缺陷:
// - 缺 icon / 空串 ⇒ 侧边栏空白(channels 的实测症状)
// - emoji ⇒ 尺寸不跟原生 tab、忽略主题色(billing 的历史缺陷)
func TestEveryPagePluginDeclaresSVGIcon(t *testing.T) {
for _, p := range pagePlugins {
t.Run(p, func(t *testing.T) {
icons := pageIcons(t, p)
if len(icons) == 0 {
t.Fatalf("%s 有任何 page 却没有任何 icon 声明 —— 侧边栏那条会是空白", p)
}
// 只断言"至少有一个非空且是 SVG":一个文件可能有多个页面,
// 但不能为了通过而声明一个空图标。
var ok bool
for _, ic := range icons {
if strings.TrimSpace(ic) == "" {
continue
}
if isEmojiIcon(ic) {
t.Errorf("%s 的 page icon 是 emoji %q —— 原生 tab 用 inline SVG"+
"(stroke: currentColor),emoji 尺寸不一致且不跟主题", p, ic)
continue
}
if !strings.Contains(ic, "<svg") {
t.Errorf("%s 的 page icon 不是 SVG 标记: %q", p, ic)
continue
}
ok = true
}
if !ok {
t.Errorf("%s 没有可用的 SVG page icon(声明的全是空串/emoji/非 SVG)", p)
}
})
}
}
// TestIconUsesCurrentColor 钉住图标**跟随主题色**。
//
// 参考插件的图标工厂(src/sponsor.js:52)统一用
// `stroke="currentColor"` + `strokeWidth 1.6`。写死颜色("#333"、"black")
// 会让图标在深色主题下几乎看不见 —— 那是同一个"图标不对"投诉的另一半。
func TestIconUsesCurrentColor(t *testing.T) {
for _, p := range pagePlugins {
t.Run(p, func(t *testing.T) {
for _, ic := range pageIcons(t, p) {
if strings.TrimSpace(ic) == "" || isEmojiIcon(ic) {
continue
}
if !strings.Contains(ic, "currentColor") {
t.Errorf("%s 的 icon 没写 stroke/fill=currentColor —— "+
"写死颜色在深色主题下不可见: %s", p, ic)
}
// 写死颜色的显式反例:出现 #rrggbb 或 rgb( 就报
if regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|rgba?\(`).MatchString(ic) {
t.Errorf("%s 的 icon 里含写死颜色: %s", p, ic)
}
}
})
}
}

View File

@ -602,7 +602,7 @@ plugin.ui = {
page = {
page_id = "billing",
title = "Billing",
icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
order = 40,
mount = [==[
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">

File diff suppressed because it is too large Load Diff

View File

@ -1065,6 +1065,9 @@ PLUGIN.ui = {
page = {
page_id = "quota",
title = "Quota",
-- ★ 侧边栏图标必须是 **inline SVG**(空串=空白,emoji=尺寸/主题不一致)。
-- 图案:三格电池 —— “套餐余量”的直接语义。
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="16" height="10" rx="2.4"/><path d="M21.5 10.5v3"/><path d="M6 10.5v3M10 10.5v3M14 10.5v3"/></svg>]==],
order = 45,
mount = [==[
<div id="qa-root" class="qa-root">

View File

@ -93,6 +93,12 @@ type staticInfo struct {
// but once an adapter claims the endpoint, protocol iteration lives in the
// adapter, not the kernel. Empty means the adapter claims no ingress.
ingress string
// hidden marks an adapter that should NOT appear in the user-facing adapter
// list (GET /api/adapters → UI 的适配器页与源编辑器的适配器下拉)。隐藏的
// 适配器仍然正常加载、仍然可以被源引用——它只是不从选择列表里冒出来。
// 用途:厂商探针类/内部实验类适配器(如 opencodezen 的免费层门禁形态),
// 直接暴露会让运维误当成通用 OpenAI 适配器去选。声明方式:adapter.hidden = true。
hidden bool
}
// adapterPool owns the worker states for one adapter. Idle workers are held in
@ -596,6 +602,11 @@ type VM struct {
dir string
pools map[string]*adapterPool
// hiddenRefs 记录"被源引用的隐藏适配器"。hidden=true 的适配器默认不进
// ListAdapters,但被源引用时必须保持可见(源编辑器要能回显当前值)。
// 由内核在配置装载/源变更时调用 SetHiddenRefs 维护;nil = 未标记。
hiddenRefs map[string]bool
// janitor drives elastic shrinking. One goroutine serves every pool (not
// one per adapter), so an idle gateway costs a single sleeping goroutine.
janitorOnce sync.Once
@ -603,6 +614,21 @@ type VM struct {
janitorDone chan struct{}
}
// SetHiddenRefs 供内核同步"哪些隐藏适配器正被源引用"。传 nil/空集 = 没有引用。
func (v *VM) SetHiddenRefs(names []string) {
v.mu.Lock()
defer v.mu.Unlock()
if len(names) == 0 {
v.hiddenRefs = nil
return
}
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
v.hiddenRefs = m
}
func NewVM(dir string) *VM {
return &VM{
dir: dir,
@ -799,14 +825,32 @@ func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
func (v *VM) ListAdapters() []APIAdapter {
v.mu.RLock()
defer v.mu.RUnlock()
// hidden=true 的适配器不进用户可见列表(仍正常加载、仍可被源引用)。
// 例外:**正在被某个源引用**的隐藏适配器必须仍出现在列表里——否则源编辑器
// 打开时找不到当前值,下拉会被重置成空,造成"保存后适配器悄悄丢了"。
// 引用关系由内核持有(core 在配置装载时标记),VM 只看这个位。
keepHidden := v.hiddenKeep()
list := make([]APIAdapter, 0, len(v.pools))
for name, p := range v.pools {
if p.static.hidden && !keepHidden[name] {
continue
}
list = append(list, APIAdapter{Name: name, Version: p.static.version})
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
return list
}
// hiddenKeep returns the set of hidden adapter names that must STAY visible
// because at least one source references them. nil 表示内核未标记(测试环境/
// 未装载配置),此时隐藏适配器一律隐藏——与"新适配器默认隐藏"的预期一致。
func (v *VM) hiddenKeep() map[string]bool {
if v.hiddenRefs == nil {
return nil
}
return v.hiddenRefs
}
// AdapterForIngress returns the registered name of the adapter that CLAIMS the
// given client-facing endpoint path (its static.ingress), or "" when none does.
//
@ -1255,6 +1299,10 @@ func inspectScript(name, code string) (*staticInfo, error) {
info.endpoint = readString(L, -1, "endpoint")
info.endpointStream = readString(L, -1, "endpoint_stream")
info.ingress = readString(L, -1, "ingress")
// hidden:适配器可声明 adapter.hidden = true 退出用户可见列表(仍可被源引用)。
L.GetField(-1, "hidden")
info.hidden = L.ToBoolean(-1)
L.Pop(1)
if getFieldBatch(L, "headers") {
luaTableToMap(L, -1, info.headers)
L.Pop(1)

View File

@ -0,0 +1,49 @@
package lua
import "testing"
// hidden=true 的适配器:不被任何源引用时从 ListAdapters 消失;被引用时保持可见。
// 这个行为支撑"内部适配器默认隐藏"的约定(opencodezen 是第一个使用者)。
func TestAdapterHiddenFlag(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
const src = `
local adapter = {}
adapter.name = "secretone"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.hidden = true
function adapter.transform_request(raw) return raw end
function adapter.transform_response(raw) return raw end
return adapter
`
if err := vm.LoadAdapterSource("secretone", src); err != nil {
t.Fatal(err)
}
// 未被引用 → 隐藏
vm.SetHiddenRefs(nil)
for _, a := range vm.ListAdapters() {
if a.Name == "secretone" {
t.Fatal("hidden adapter must not be listed when unreferenced")
}
}
// 被引用 → 可见
vm.SetHiddenRefs([]string{"secretone"})
found := false
for _, a := range vm.ListAdapters() {
if a.Name == "secretone" {
found = true
}
}
if !found {
t.Fatal("hidden adapter must stay listed while referenced by a source")
}
// transform 路径不受 hidden 影响:隐藏 ≠ 禁用
out, err := vm.Transform("secretone", "transform_response", `{"x":1}`)
if err != nil || out == "" {
t.Fatalf("hidden adapter must still transform: %v %q", err, out)
}
}

View File

@ -506,42 +506,39 @@ func (p *Provider) ModelFor(reqModel string) string {
return p.bestChatModel()
}
// bestChatModel returns the highest-priority chat-kind model of this source.
// bestChatModel returns the first chat-kind model of this source.
//
// ★ 曾经叫 "highest-priority chat model",但 `config.Model.Priority` 已删除
//
// (它从未参与跨源调度,只在这里用;而这里的语义其实只是"选一个能发 chat 的
// 模型",选第一个就够了 —— 源内模型顺序由配置/发现顺序决定)。
func (p *Provider) bestChatModel() string {
bestID, bestPrio := "", -1
for _, m := range p.cfg.Models {
if m.Kind != "" && m.Kind != "chat" {
continue
}
if m.Priority > bestPrio {
bestPrio = m.Priority
bestID = m.ID
}
return m.ID
}
if bestID == "" && len(p.cfg.Models) > 0 {
bestID = p.cfg.Models[0].ID
if len(p.cfg.Models) > 0 {
return p.cfg.Models[0].ID
}
return bestID
return ""
}
// bestImageModel returns the highest-priority image-kind model of this source
// (fallback: first configured model). Used for AUTO image generation so a
// mixed source never sends a chat model to /v1/images/generations.
// bestImageModel returns the first image-kind model of this source (fallback:
// first configured model). Used for AUTO image generation so a mixed source
// never sends a chat model to /v1/images/generations.
func (p *Provider) bestImageModel() string {
bestID, bestPrio := "", -1
for _, m := range p.cfg.Models {
if m.Kind != "" && m.Kind != "image" {
continue
}
if m.Priority > bestPrio {
bestPrio = m.Priority
bestID = m.ID
}
return m.ID
}
if bestID == "" && len(p.cfg.Models) > 0 {
bestID = p.cfg.Models[0].ID
if len(p.cfg.Models) > 0 {
return p.cfg.Models[0].ID
}
return bestID
return ""
}
// IsAutoID reports whether s is an AUTO routing placeholder.
@ -1263,6 +1260,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
var realChunks int
var doneSeen bool
var doneSent bool
var sawToolCalls bool
// toolIdx maps a per-stream tool fragment's upstream index (Anthropic
// content-block ordinal, which is sparse when thinking/text blocks
// precede tool_use) to a dense 0-based OpenAI tool_call ordinal so
@ -1306,6 +1304,19 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue
}
// A few OpenAI-compatible upstreams emit a final `stop` marker
// after streaming tool-call fragments. Once a real fragment was
// observed, the gateway must preserve tool-call semantics for the
// agent instead of making it treat the turn as ordinary text.
if len(ck.ToolCalls) > 0 {
trimmed := strings.TrimSpace(string(ck.ToolCalls))
if trimmed != "null" && trimmed != "[]" && trimmed != "{}" {
sawToolCalls = true
}
}
if sawToolCalls && ck.FinishReason == "stop" {
ck.FinishReason = "tool_calls"
}
ck.ToolCalls = toolIdx.remap(ck.ToolCalls)
if ck.Done {
doneSent = true
@ -1459,8 +1470,18 @@ func (p *Provider) apiErrReason(status int, raw string) string {
return fmt.Sprintf("api error %d: %s", status, types.OneLine(trimmed, 200))
}
}
log.Printf("[provider] unhandled upstream error body (adapter %q lacks transform_error): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
// ⚠️ 措辞必须**区分两种原因**(实测教训):适配器**有** transform_error 钩子
// 但它返回 nil(不认识的错误体)是**常见**情况,而"真的没这个钩子"是另一回事。
// 原先一律说 "lacks transform_error" —— 我照着这句话去适配器里找那个函数,
// 查了几轮才发现函数明明在、只是抽不出消息。
// 这里直接问内核要事实,不再猜(判据也据此断言)。
if p.vm.HasFn(p.adapter, "transform_error") {
log.Printf("[provider] unhandled upstream error body (adapter %q: transform_error returned no reason for this shape): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
} else {
log.Printf("[provider] unhandled upstream error body (adapter %q defines no transform_error): status=%d body=%.300s",
p.adapter, status, types.OneLine(raw, 300))
}
return fmt.Sprintf("api error %d: unknown error", status)
}

View File

@ -32,7 +32,7 @@ func newTestProvider(t *testing.T, src config.Source) *Provider {
func src(name, url, adapter string, models ...string) config.Source {
s := config.Source{Name: name, BaseURL: url, Adapter: adapter, MaxConcurrent: 4}
for _, m := range models {
s.Models = append(s.Models, config.Model{ID: m, Priority: 0})
s.Models = append(s.Models, config.Model{ID: m})
}
return s
}
@ -93,7 +93,39 @@ func TestProviderChatStream(t *testing.T) {
}
}
// TestStreamSuccessClearsBackoff guards P6: a clean streaming end must reset
func TestProviderChatStreamNormalizesStopAfterToolCall(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"f"}}]}}]}`+"\n\n")
fmt.Fprint(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":"stop"}]}`+"\n\n")
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
Model: "m", Stream: true,
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
})
if err != nil {
t.Fatalf("stream: %v", err)
}
var sawTool, sawToolFinish bool
for ck := range ch {
if len(ck.ToolCalls) > 0 && string(ck.ToolCalls) != "{}" {
sawTool = true
}
if ck.FinishReason == "tool_calls" {
sawToolFinish = true
}
}
if !sawTool {
t.Fatal("stream dropped the tool-call fragment")
}
if !sawToolFinish {
t.Fatal("stop after a tool call was not normalized to tool_calls")
}
}
// a previously cooled (source, model) pair.
func TestStreamSuccessClearsBackoff(t *testing.T) {
var fail atomic.Bool
@ -515,8 +547,8 @@ func TestImageAutoUsesImageModel(t *testing.T) {
defer up.Close()
s := config.Source{Name: "mix", BaseURL: up.URL, Adapter: "openai", MaxConcurrent: 4}
s.Models = []config.Model{
{ID: "chat-m", Kind: "chat", Priority: 100},
{ID: "img-m", Kind: "image", Priority: 50},
{ID: "chat-m", Kind: "chat"},
{ID: "img-m", Kind: "image"},
}
p := newTestProvider(t, s)
resp, err := p.Image(context.Background(), &types.ImageGenRequest{Prompt: "cat", Model: "AUTO"})

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