Compare commits

...

8 Commits

Author SHA1 Message Date
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
12 changed files with 1378 additions and 117 deletions

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

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

@ -0,0 +1,190 @@
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
if !strings.Contains(s, `logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)`) {
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属")
}
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

@ -48,6 +48,13 @@ func TestAccountDeleteRemovesItsSource(t *testing.T) {
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)
})
}

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

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

@ -0,0 +1,201 @@
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 的 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
function adapter.transform_response(raw_body)
-- 非流式 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 delta.tool_calls then
-- Sanitize on the way OUT too: an id this upstream happily minted (it
-- does not validate them) becomes a landmine once the client replays
-- it to a Claude upstream. Only the first fragment of a streamed call
-- carries an id; later argument fragments have none and are untouched.
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

@ -11,8 +11,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。
--
@ -219,26 +225,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

@ -47,6 +47,36 @@ local plugin = {
author = "llmsproxy",
}
-- ★★ 顶层常量集中到 plugin.K:本文件主 chunk 的 local 数已**顶到 Lua 上限 200**,
-- 再加一个顶层 local 就编译失败(实测 +1 即 `main function has more than 200
-- local variables`),而且炸的是**整个渠道插件**(插件加载失败 = 全渠道不可用)。
-- 挂表字段**不占** local 槽位,故把纯常量收进这里腾出余量。
--
-- ⚠️ 只搬**纯常量**(字符串/数字/自闭合字面量):它们没有前向引用问题,
-- 搬迁是纯机械的。函数保持 local(它们之间的调用顺序是真实依赖)。
plugin.K = {
CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com",
CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect",
HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html",
CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains",
CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3",
QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000,
QODER_REFRESH_HOUR_UTC8 = 10,
QODER_MACHINE_ENVIRONMENT = "3",
STORE_NAME = ".channels-store.json",
B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
B64_LOOKUP = {},
SESSION_TTL = 900, -- 15 分钟,过期会话在读取时惰性清理
MODELS_DEV_URL = "https://models.dev/api.json",
MODELS_DEV_TTL = 6 * 3600, -- 6 小时(照参考的 TTL 量级)
EXPIRING_WINDOW_DAYS_DEFAULT = 15,
BALANCE_CACHE_TTL = 60,
DEAD_MODEL_TTL_DAYS = 30,
REMOTE_CATALOG_COOLDOWN = 30, -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
INJECT_PLATFORM_LIMIT = 32,
ZCODE_ORIGIN = "https://zcode.z.ai",
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
-- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。
--
@ -64,7 +94,7 @@ plugin.kernel = {
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
@ -183,7 +213,7 @@ local PLATFORMS = {
adapter = "channels-trae",
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
models = {
models = {
"DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430",
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
@ -344,7 +374,13 @@ local PLATFORMS = {
},
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
base_url = "https://api.cline.bot/api/v1/chat/completions",
base_url = "https://api.cline.bot",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/api/v1/chat/completions",
-- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。
-- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由
-- recommended-models 下发;只打前者会看不到任何免费模型。
@ -414,7 +450,13 @@ local PLATFORMS = {
},
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
-- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。
base_url = "https://copilot.tencent.com/v2/chat/completions",
base_url = "https://copilot.tencent.com",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/v2/chat/completions",
-- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。
-- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。
models_remote = {
@ -423,7 +465,11 @@ local PLATFORMS = {
headers_fn = true,
parse = "buddy_config",
},
adapter = "openai",
-- ★ 用内置 buddy 适配器(而非通用 openai):buddy 的 glm-5.1 要求请求体里
-- 必须有非空 reasoning_effort(否则 400 + 11150),且流式请求必须带
-- Accept: text/event-stream(否则上游只回空帧、completion_tokens=0)。
-- 该适配器复用 openai 的全部转换,只补这两处厂商要求。
adapter = "buddy",
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
@ -449,7 +495,10 @@ local PLATFORMS = {
return h
end,
models = {
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
-- 兜底目录(远端不可用时才用)。照参考 CODEBUDDY_FALLBACK_MODELS 的取舍:
-- **只列实测可调用的**。hy4-preview-x 已实测恒 11102(后端未开放)故剔除
-- —— 参考的兜底表里同样没有它(它靠白名单达到同样效果)。
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview",
"glm-5.3", "glm-5.3-flash", "glm-5.2",
},
-- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用
@ -688,7 +737,13 @@ local PLATFORMS = {
fallback = "2026.9.4",
},
-- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。
base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions",
base_url = "https://lobsterai-server.youdao.com",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/api/proxy/v1/chat/completions",
-- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH)
models_remote = {
-- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。
@ -727,7 +782,17 @@ local PLATFORMS = {
vendor = "opencode",
flow = "paste",
-- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。
base_url = "https://opencode.ai/zen/v1/chat/completions",
-- ★ base_url 必须保留 `/zen` 段:参考 opencode-product.ts:56 的
-- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。
-- 实测(无凭据):
-- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸)
-- https://opencode.ai/v1/chat/completions → 404 + HTML(网页)
base_url = "https://opencode.ai/zen",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
-- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。
endpoint = "/v1/chat/completions",
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
models_remote = {
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
@ -853,7 +918,13 @@ local PLATFORMS = {
interval = 2,
},
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
base_url = "https://api2-v2.qoder.sh",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/model/v1/chat/completions",
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
@ -1007,9 +1078,24 @@ local PLATFORMS = {
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
app_id = "GM3LOOMY",
},
-- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494
-- 实测标准 OpenAI 兼容)。
base_url = "https://loomy.xunfei.cn/chat/completions",
-- loomy 的 chat 端是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494)。
--
-- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是
-- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的
-- wechat.redirect_uri 正用它)。实测:
-- POST https://loomy.xunfei.cn/chat/completions
-- → 200 + `<!DOCTYPE html>`(网页!根本不是 API)
-- POST https://loomyad.xunfei.cn/api/v1/chat/completions
-- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON)
-- 写成前端域时,无论路径怎么拼都不可能是 API。
--
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
-- .../chat/completions/chat/completions ⇒ 上游回
-- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
base_url = "https://loomyad.xunfei.cn/api/v1",
endpoint = "/chat/completions",
adapter = "openai",
-- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。
models = {
@ -1044,7 +1130,13 @@ local PLATFORMS = {
},
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
-- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。
base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions",
base_url = "https://xiaohuanxiong.com",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/api/web/llm/v2/chat/completions",
adapter = "openai",
-- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。
-- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。
@ -1077,7 +1169,6 @@ local PLATFORMS = {
}
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
--
@ -1156,7 +1247,7 @@ end
-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。
local function codeartsAccountInfo(cred)
local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
local req, err = codeartsSignedRequest("GET", plugin.K.CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
if not req then return nil, err end
local resp, herr = __llmsproxy_http(req)
if herr or not resp or resp.status ~= 200 then
@ -1202,7 +1293,7 @@ local function codeartsClaim(plat, tok, args)
-- ② 活动列表
local areq = codeartsSignedRequest("GET",
CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
plugin.K.CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
if not areq then return nil, "活动列表请求构造失败" end
local aresp, aerr = __llmsproxy_http(areq)
if aerr or not aresp or aresp.status ~= 200 then
@ -1237,7 +1328,7 @@ local function codeartsClaim(plat, tok, args)
-- ④ claim
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
local creq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
if not creq then return nil, "claim 请求构造失败" end
local cresp, cerr = __llmsproxy_http(creq)
if cerr or not cresp or cresp.status ~= 200 then
@ -1251,7 +1342,7 @@ local function codeartsClaim(plat, tok, args)
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
if type(benefit) == "table" and benefit.id then
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
local freq = codeartsSignedRequest("POST", plugin.K.CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
if freq then
local fresp, ferr = __llmsproxy_http(freq)
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
@ -1298,7 +1389,7 @@ end
-- OAUTH_THEME = 2(Dark) OAUTH_LOCALE = zh-cn
-- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth,
-- 错值会回退到旧 ticket 流程)
-- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
-- 旧流程回退(ticket):plugin.K.CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket
-- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。
-- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让
-- 整个插件**加载失败**(`too many local variables`);报错在编译期,
@ -1529,8 +1620,8 @@ local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
local resp = __llmsproxy_http({
url = url, method = "GET",
headers = { ["Content-Type"] = "application/json;charset=UTF-8",
["plugin-name"] = pluginName or CODEARTS_TICKET_PLUGIN_NAME,
["plugin-version"] = pluginVersion or CODEARTS_TICKET_PLUGIN_VERSION },
["plugin-name"] = pluginName or plugin.K.CODEARTS_TICKET_PLUGIN_NAME,
["plugin-version"] = pluginVersion or plugin.K.CODEARTS_TICKET_PLUGIN_VERSION },
})
if resp and resp.status == 200 then
local ok, rec = pcall(json.decode, resp.body or "")
@ -1559,12 +1650,8 @@ local function codeartsTicketPoll(ticketId, secret, pluginName, pluginVersion)
end
-- 旧 ticket 流程(portal 判定新式不可用时回退)
local CODEARTS_LOGIN_BASE = "https://devcloud.cn-north-4.huaweicloud.com/doer/redirect"
local HUAWEI_AUTH_BASE = "https://auth.huaweicloud.com/authui/login.html"
local CODEARTS_CREDENTIAL_ENDPOINT =
"https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/login/ticket"
local CODEARTS_TICKET_PLUGIN_NAME = "snap_jetbrains"
local CODEARTS_TICKET_PLUGIN_VERSION = "26.3.3"
-- 生成 PKCE 配对(照参考 generatePkcePair:verifier = 48 随机字节的 base64url,
-- challenge = sha256(verifier) 的 base64url)。
@ -1603,8 +1690,6 @@ end
QODER_SASH_BASE = "https://openapi.qoder.sh"
-- UTC+8 偏移(毫秒)。活动按 UTC+8 结算,口径与参考一致。
local QODER_UTC8_OFFSET_MS = 8 * 3600 * 1000
local QODER_REFRESH_HOUR_UTC8 = 10
-- hasQoderCampaignRefreshedToday():今天的活动列表是否已刷新。
local function qoderCampaignRefreshed(nowMs)
@ -1614,8 +1699,8 @@ local function qoderCampaignRefreshed(nowMs)
-- **必须用算术平移**判 UTC+8,不能用 os.date("*t")(本机时区):
-- 活动按 UTC+8 结算,取本机时区在出差/改时区时会判错方向
-- (偏东提前把当天记为已处理、真漏领;偏西一天判两次)。
local utc8 = os.date("!*t", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
return (utc8.hour or 0) >= QODER_REFRESH_HOUR_UTC8
local utc8 = os.date("!*t", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
return (utc8.hour or 0) >= plugin.K.QODER_REFRESH_HOUR_UTC8
end
-- 读数字/字符串(容忍字符串形式与缺失),照参考 readNumber/readString
@ -1833,7 +1918,6 @@ end
-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级,
-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。
-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。
local QODER_MACHINE_ENVIRONMENT = "3"
local function qoderMachineCache()
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
@ -1910,7 +1994,7 @@ local function qoderReadFromRuntimeInfo()
f:write('{"account":""}')
f:close()
local cmd = ("timeout 20 %s %s %s < %s 2>/dev/null"):format(
exe, QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
exe, plugin.K.QODER_MACHINE_ENVIRONMENT, "--account-stdin", tmp)
local h = io.popen(cmd)
local out = h and h:read("*a") or ""
if h then h:close() end
@ -2504,7 +2588,6 @@ end
-- 即便这个文件泄露,也不含任何 token。
--
-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
local STORE_NAME = ".channels-store.json"
-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
@ -2563,7 +2646,7 @@ local PERSIST_KEYS = {
local function persistState()
local dir = storeDir()
local path = dir .. "/" .. STORE_NAME
local path = dir .. "/" .. plugin.K.STORE_NAME
local snap = {}
for _, k in ipairs(PERSIST_KEYS) do
if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
@ -2618,7 +2701,7 @@ end
-- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。
local function loadOwnSnapshot()
local dir = storeDir()
local f = io.open(dir .. "/" .. STORE_NAME, "r")
local f = io.open(dir .. "/" .. plugin.K.STORE_NAME, "r")
if f then
local txt = f:read("*a")
f:close()
@ -2654,7 +2737,7 @@ local function migrateLegacyKernelState()
local written, werr = persistState()
if written then
os.remove(legacy)
io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n")
io.stderr:write("[channels] 已迁移遗留状态到 " .. plugin.K.STORE_NAME .. "\n")
else
io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
.. tostring(werr) .. "\n")
@ -2717,9 +2800,70 @@ do
if sh then sh:close() end
end
local function logEvent(msg)
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
-- utf8Clip 按**字符**截断(不是字节),并保证结果仍是合法 UTF-8。
--
-- ★ 为什么不能用 `s:sub(1, N)`:Lua 的 sub 按**字节**切,而中文字符占 3 字节
-- ⇒ 切在字符中间就产生**无效 UTF-8**,前端显示成 `能力增强不<E5BCBA>` 这种乱码尾巴。
-- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符,
-- 看起来像数据损坏,实际是截断方式错。
--
-- 做法:先按字节切,再回退到最后一个完整 UTF-8 序列边界。
-- 只在必要时回退(最多退 2 字节),所以不会显著缩短内容。
plugin.utf8Clip = function(s, maxBytes)
s = tostring(s or "")
if #s <= maxBytes then return s end
local cut = s:sub(1, maxBytes)
-- 回退掉尾部不完整的 UTF-8 续字节(10xxxxxx)与被切半的首字节
for _ = 1, 4 do
local b = cut:byte(-1)
if not b then break end
if b < 0x80 then break -- ASCII,完整
elseif b >= 0xC0 then -- 首字节但没有续字节收尾 ⇒ 整字符不完整
cut = cut:sub(1, -2)
break
else -- 续字节,继续往前退
cut = cut:sub(1, -2)
end
end
return cut
end
-- logEvent 写一条插件事件日志(带级别与渠道归属)。
--
-- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加:
-- 实测生产 50 条日志里 **48 条(96%)** 是三个定时任务
-- (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
-- 的重复回音,唯一模式只有 8 种 ⇒ 约 **2 小时**就把 50 条环形缓冲冲满一轮,
-- **真正的用户动作**(如「账号删除」)被挤到看不见 —— 面板沦为
-- 「最近两小时的定时任务回音」。
--
-- 三条措施:
-- ① `level` ∈ info|task|warn|error。定时任务的**常规**结果记 "task"
-- (前端默认折叠),只有异常才 warn/error。
-- ② **紧邻同文折叠**:与**上一条**完全同文时不新增条目,只 n=n+1
-- 并刷新时间戳。周期任务的连续回音于是合并成一条(显示 ×9),
-- 而无需给每个调用点判定「这次结果变了没」(那要引入状态比较)。
-- ⚠️ 只在**紧邻**时折叠:否则一条每小时出现的消息会不断"跳"到最前,
-- 时间线就失去意义。
-- ③ `platform`:记录归属,前端按当前选中渠道过滤。
-- ⚠️ 历史 state 里的老条目没有这个字段,前端必须容忍 nil。
local function logEvent(msg, level, platform)
local log = plugin.state.log
local now = os.time and os.time() or 0
local top = log[1]
if top and top.msg == msg and (top.level or "info") == (level or "info") then
top.n = (top.n or 1) + 1
top.at = now
markDirty()
return
end
table.insert(log, 1, {
at = now,
msg = msg,
level = level or "info",
platform = platform,
})
while #log > 50 do table.remove(log) end
markDirty()
end
@ -2823,16 +2967,14 @@ end
-- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 ——
-- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。
-- 接受标准/URL-safe 两种字母表,容忍缺失填充。
local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local B64_LOOKUP = {}
for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end
for i = 1, #plugin.K.B64_ALPHABET do plugin.K.B64_LOOKUP[plugin.K.B64_ALPHABET:sub(i, i)] = i - 1 end
local function b64url_decode(input)
if type(input) ~= "string" or input == "" then return nil end
-- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理)
local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
local out, acc, bits = {}, 0, 0
for i = 1, #s do
local v = B64_LOOKUP[s:sub(i, i)]
local v = plugin.K.B64_LOOKUP[s:sub(i, i)]
if v == nil then return nil end -- 非法字符:如实失败,不猜
acc = acc * 64 + v
bits = bits + 6
@ -3161,17 +3303,98 @@ local function parseRemoteModels(kind, body, provider)
end
end
elseif kind == "buddy_config" then
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
local d = rec.data or rec
local arr = d.models or d.agents or rec.models
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.id or m.modelId or m.name)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
elseif kind == "buddy_config" then
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
--
-- ★★ 必须过滤「不可调用」的条目 —— 远端 data.models 里混着三类**不是对话
-- 模型**的东西,列进模型选择器只会让用户选中后报错(实测生产:glm-5.1
-- 与 glm-5.2 之外,codewise-* / nes-gf / hunyuan-image-alpha 同样调不通)。
-- 判据与命名逐条照参考 buddy.ts 的 isChatModel / isAutoSelectAlias:
-- ① 自动选择类内部别名:`auto` / `default`
-- ⚠️ 只按**这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤
-- `default-model` / `fast-model`(国际版抽象别名,但被 craft agent
-- 引用,是官方推荐入口)。
-- ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,
-- 或带 `supportsExtra` 标记。参考实测 codewise-default-model-v2
-- 返回 `code 11102 model service info not found`(后端未开放)。
-- ③ 输出上限过小(>0 且 ≤256):补全用途,对话模型普遍 ≥24000。
-- ④ 带 `text-to-image` 标签的生成式模型(如 hunyuan-image-alpha)。
-- ⚠️ ③④ 需要元数据,故先把 models 建成 id→meta 映射。
local d = rec.data or rec
local arr = d.models or d.agents or rec.models
local metaById = {}
if type(arr) == "table" then
for _, m in ipairs(arr) do
if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then
metaById[m.id] = m
end
end
end
end
-- 被 agent 引用的模型(craft/ask/plan/cli)。参考据此给 `agentReferenced`
-- 打标:`hy4-preview-f` 只由本端点下发且被 agent 引用,白名单重建会丢掉它。
local agentRef = {}
if type(d.agents) == "table" then
for _, ag in ipairs(d.agents) do
if type(ag) == "table" and type(ag.models) == "table" then
for _, mid in ipairs(ag.models) do
if type(mid) == "string" then agentRef[mid] = true end
end
end
end
end
-- ★ 已实测**不可调用**的模型(上游恒回 11102 "model service info not found")。
--
-- 为什么需要它:这类模型既**不是**前缀可判(不像 nes-/codewise-),也**不在**
-- agent 引用清单之外 —— 实测 `hy4-preview-x` 被服务端 agents 引用,
-- 于是会被下面的 agentRef 例外重新放回目录,用户选中就 400。
-- 参考的做法是**白名单重建**(只保留编译期实测过的 id),本仓没有那份表,
-- 故用等价的**黑名单**精确排除:只放实测确认调不通的,不做任何猜测。
--
-- ⚠️ 判据来源必须是**真实请求**,不是元数据:
-- 实测 hy4-preview-x 加/不加 reasoning_effort 都是 11102 ⇒ 与 effort 无关,
-- 是后端未开放(与 glm-5.1 的 11150 是**不同**原因,后者已被 adapter 修好)。
-- 参考 product.ts 的兜底白名单里同样**没有** hy4-preview-x。
local NOT_CALLABLE = {
["hy4-preview-x"] = true,
}
local function buddyIsChatModel(id)
if NOT_CALLABLE[id] then return false end
if id == "auto" or id == "default" then return false end
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
or id:sub(1, 9) == "codewise-" then return false end
local m = metaById[id]
if type(m) == "table" then
if m.supportsExtra == true then return false end
local mo = tonumber(m.maxOutputTokens)
if mo and mo > 0 and mo <= 256 then return false end
if type(m.tags) == "table" then
for _, t in ipairs(m.tags) do
if t == "text-to-image" then return false end
end
end
end
return true
end
local seen = {}
local function pushId(id)
if type(id) ~= "string" or id == "" then return end
if seen[id] or not buddyIsChatModel(id) then return end
seen[id] = true
out[#out + 1] = id
end
-- models 与 agents 两处都收(agents 里是被引用的清单,可能含远端
-- data.models 没有的新变体)。models 优先,保持顺序权威。
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.id or m.modelId or m.name) or nil
if type(id) == "string" then pushId(id) end
end
end
-- agentRef 的例外也必须过 NOT_CALLABLE 闸:否则"被 agent 引用"会把
-- 实测不可调的模型重新放回目录(hy4-preview-x 正是这样漏进来的)。
for id in pairs(agentRef) do pushId(id) end
elseif kind == "codearts_builtin" then
-- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels
@ -3654,14 +3877,14 @@ local function syncAccountSource(provider, accountId)
-- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。
-- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露),
-- 故失败仍记一笔(低频,不会刷屏)。
local rr = __llmsproxy_kernel.call("remove_source", { name = name })
if not (rr and rr.ok) then
local rr, rerr = __llmsproxy_kernel.call("remove_source", { name = name })
if rr == nil or rr.ok ~= true then
local l = plugin.state.log
local last = type(l) == "table" and l[1] and l[1].msg or ""
-- 只在**上一次不是同一条**时记,避免每次同步都刷一条
if not tostring(last):find("撤源(重建)失败", 1, true) then
logEvent("撤源(重建)失败 " .. tostring(name) .. ": "
.. tostring(rr and (rr.error or rr.err) or "无返回"))
.. tostring(rerr or "(无错误信息)"), "warn", provider)
end
end
local src = {
@ -3694,11 +3917,11 @@ local function syncAccountSource(provider, accountId)
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
if aerr then
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr))
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider)
elseif r and r.json and r.json.ok == false then
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error))
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider)
else
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)")
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)
end
end
@ -3713,12 +3936,16 @@ end
-- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。
local function dropAccountSource(provider, accountId)
local name = accountSourceName(provider, accountId)
local r = __llmsproxy_kernel.call("remove_source", { name = name })
local ok = r and r.ok
if not ok then
local err = (r and (r.error or r.err)) or "无返回"
logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err)
.. "(若提示 not granted,需给 channels 插件授予 remove_source 权限)")
-- ★ 内核桥是 `call(name, args) -> (result, nil) | (nil, err)`:
-- 失败时**第一个返回值是 nil**,原因在**第二个返回值**里。
-- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回",
-- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的
-- 诊断比没有更糟。两个返回值都要接。
local r, err = __llmsproxy_kernel.call("remove_source", { name = name })
if r == nil or r.ok ~= true then
logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err or "(无错误信息)")
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)",
"error", provider)
return false, err
end
return true
@ -3740,7 +3967,6 @@ end
-- 登录会话放 plugin.state.sessions:内核没有"登录"概念,这正是插件该自己干的
-- 事。表结构 { id, platform, flow, url, user_code, nonce, state, account,
-- created_at, expires_at }。nonce 只在 begin 响应里出现一次,存侧不外泄。
local SESSION_TTL = 900 -- 15 分钟,过期会话在读取时惰性清理
newToken = function()
-- 用 hmac 自己跟自己造一个不可猜的 id(内核给过 sha256_hex)
@ -4759,7 +4985,7 @@ local function initLoginSession(plat, args, now)
nonce = newToken():sub(1, 32),
state = "pending",
created_at = now,
expires_at = now + SESSION_TTL,
expires_at = now + plugin.K.SESSION_TTL,
}
if plat.id == "lobsterai" then
local seed = newToken()
@ -5869,8 +6095,6 @@ plugin.schedule = {
-- ⚠️ 我们额外的一条:本插件是**服务端**进程、没有"渲染路径",
-- 但 5MB 的下载仍不能放在请求路径上 —— 故挂在 refresh_models 定时任务里,
-- 与模型目录刷新同一节拍。请求路径只读缓存。
local MODELS_DEV_URL = "https://models.dev/api.json"
local MODELS_DEV_TTL = 6 * 3600 -- 6 小时(照参考的 TTL 量级)
--
-- ⚠️ **已知取不到,属设计约束而非故障**:该端点实测 **5.64 MB**,而内核的
-- `__llmsproxy_http` 有 **4 MiB 响应上限**(`httpMaxResponse`,刻意的堆保护 ——
@ -5933,11 +6157,11 @@ end
local function refreshCapabilities()
local cached = plugin.state.capabilitiesMeta
local now = os.time and os.time() or 0
if cached and cached.at and cached.at <= now and (now - cached.at) < MODELS_DEV_TTL then
if cached and cached.at and cached.at <= now and (now - cached.at) < plugin.K.MODELS_DEV_TTL then
return false, "未到刷新时间"
end
local resp, err = __llmsproxy_http({
url = MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
url = plugin.K.MODELS_DEV_URL, method = "GET", timeout_ms = 30000,
headers = { ["Accept"] = "application/json" },
})
if err or not resp or resp.status ~= 200 then
@ -6088,7 +6312,6 @@ end
-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」
-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池
-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。
local EXPIRING_WINDOW_DAYS_DEFAULT = 15
expiringWindowDays = function()
-- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。
@ -6108,7 +6331,7 @@ expiringWindowDays = function()
local n = tonumber(raw)
if n and n >= 0 then return n end
end
return EXPIRING_WINDOW_DAYS_DEFAULT
return plugin.K.EXPIRING_WINDOW_DAYS_DEFAULT
end
-- balanceTier(expiring, permanent, allow_permanent):
@ -6136,7 +6359,6 @@ end
-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
local BALANCE_CACHE_TTL = 60
-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要)
@ -6152,7 +6374,7 @@ local function accountBalanceRaw(provider, accountId)
if not tok or tok == "" then return nil end
local plate = plat
local act = acct
return ttlCacheGet("balance", provider .. ":" .. accountId, BALANCE_CACHE_TTL, function()
return ttlCacheGet("balance", provider .. ":" .. accountId, plugin.K.BALANCE_CACHE_TTL, function()
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
local rec, err = fetchAccountJSON(plate.balance, tok,
credentialFull(act.credentialRef), plate)
@ -6292,7 +6514,6 @@ local function isModelGone(message)
end
-- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。
local DEAD_MODEL_TTL_DAYS = 30
local function deadModelsOf(provider)
plugin.state.deadModels = plugin.state.deadModels or {}
@ -6315,7 +6536,7 @@ end
deadModelIds = function(provider)
local p = deadModelsOf(provider)
local now = os.time and os.time() or 0
local ttl = DEAD_MODEL_TTL_DAYS * 86400
local ttl = plugin.K.DEAD_MODEL_TTL_DAYS * 86400
local out = {}
for id, meta in pairs(p) do
local at = type(meta) == "table" and tonumber(meta.at) or 0
@ -6359,7 +6580,6 @@ end
--
-- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳
-- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。
local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS
local function catalogGateOf(provider)
plugin.state.catalogGate = plugin.state.catalogGate or {}
@ -6377,7 +6597,7 @@ local function catalogGateCooling(provider, now)
local g = catalogGateOf(provider)
if not g.retry_at then return false end
if now >= g.retry_at then return false end
if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end
if g.retry_at - now > plugin.K.REMOTE_CATALOG_COOLDOWN then return false end
return true
end
@ -6395,7 +6615,7 @@ end
local function catalogGateEnd(provider, got, now)
local g = catalogGateOf(provider)
g.in_flight_at = nil
g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN)
g.retry_at = got and nil or (now + plugin.K.REMOTE_CATALOG_COOLDOWN)
end
-- refresh_models():定时把远端模型目录拉回来并重同步所有源。
@ -6429,7 +6649,7 @@ function plugin.refresh_models()
end
-- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误)
if cooled > 0 then
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台")
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")
end
-- 顺带刷新模型能力元数据(照参考 opencode-capability)。
-- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。
@ -6437,19 +6657,27 @@ function plugin.refresh_models()
if not cok then
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120))
logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")
elseif cres == true then
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)")
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")
elseif cerr then
logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120))
logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")
end
if updated > 0 then
logEvent("模型目录已刷新(" .. updated .. " 个平台)")
logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")
end
-- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证):
-- 拉不到时继续用上次的好数据,绝不能把目录清空。
if #failed > 0 then
logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200))
-- ★ 逐平台各记一条(而不是把 N 个挤成一条汇总):这样前端按渠道过滤时,
-- 点开 trae 就只看到 trae 的刷新失败。原先挤成一条汇总是**归属丢失**的
-- 一个来源 —— 它既不属于 trae 也不属于 minimax,于是只能在「全部渠道」里
-- 出现,点进任何单渠道都看不到自己的问题。
for _, f in ipairs(failed) do
-- failed 项形如 "<platform>: <err>",拆出平台名做归属
local pid = tostring(f):match("^([%w_%-]+):") or nil
logEvent("模型刷新失败 " .. tostring(f), "warn", pid)
end
end
end
@ -6498,9 +6726,9 @@ function plugin.refresh_creds()
end
end
end
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个", "task") end
if #failed > 0 then
logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300))
logEvent("凭据续期失败 " .. #failed .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn")
end
end
@ -6509,9 +6737,9 @@ end
-- 出差/改时区会把日界判错方向。
local function todayKey()
local nowMs = (os.time and os.time() or 0) * 1000
local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000))
local utc8 = os.date("!*d", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
local utc8m = os.date("!*m", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
local utc8y = os.date("!*Y", math.floor((nowMs + plugin.K.QODER_UTC8_OFFSET_MS) / 1000))
return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8)
end
@ -8044,7 +8272,6 @@ end
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
local INJECT_PLATFORM_LIMIT = 32
-- ============================================================================
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
-- ============================================================================
@ -8204,8 +8431,8 @@ function plugin.inject_platforms(req)
if type(list) ~= "table" then
return { status = 400, json = { error = "platforms 必须是数组" } }
end
if #list > INJECT_PLATFORM_LIMIT then
return { status = 400, json = { error = "最多注入 " .. INJECT_PLATFORM_LIMIT .. " 个平台" } }
if #list > plugin.K.INJECT_PLATFORM_LIMIT then
return { status = 400, json = { error = "最多注入 " .. plugin.K.INJECT_PLATFORM_LIMIT .. " 个平台" } }
end
-- 校验:每条必须有 id,且不得与内建目录重名(注入不能劫持真实厂商)。
local builtin = {}
@ -9040,7 +9267,6 @@ end
-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium +
-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。
-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。
local ZCODE_ORIGIN = "https://zcode.z.ai"
-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求**
-- (缺它 billing 全家桶回 400)。
@ -9064,7 +9290,7 @@ end
-- zcodeClaimablePlans(cred):查可领 plan(preview)。
local function zcodeClaimablePlans(cred)
local av = tostring(cred.app_version or "1.0.0")
local url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
local url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/preview"
.. "?app_version=" .. rfc3986Escape(av) .. "&platform=win32"
local resp, err = __llmsproxy_http({
url = url, method = "GET",
@ -9094,7 +9320,7 @@ function claimZcode(plat, tok, args)
-- ① 补激活上报(照参考:不需要 Authorization)
__llmsproxy_http({
url = ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
url = plugin.K.ZCODE_ORIGIN .. "/api/v1/event/report", method = "POST",
headers = { ["Content-Type"] = "application/json" },
body = json.encode({ event = "activation", app_version = tostring(cred.app_version or "1.0.0") }),
})
@ -9123,7 +9349,7 @@ function claimZcode(plat, tok, args)
local granted, failedMsg = 0, nil
for _, planId in ipairs(plans) do
local resp, err = __llmsproxy_http({
url = ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
url = plugin.K.ZCODE_ORIGIN .. "/api/v1/zcode-plan/billing/claim", method = "POST",
headers = zcodeHeaders(cred, {
authorization = "Bearer " .. tostring(jwt),
captcha = { param = captcha.param, region = captcha.region or "cn" },
@ -10617,8 +10843,33 @@ plugin.ui = {
</div>
</div>
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
<!-- 运行日志:**全局**,不是某个渠道的。
★ 为什么必须独立成卡片(而不是留在上面「点开某渠道才显示」的渠道详情里):
日志源 plugin.state.log 是**单一全局数组**(logEvent 写入的条目原先连
platform 字段都没有),把它渲染在渠道详情卡里属于**归属错误** ——
用户点开 buddy,看到的却是 trae/minimax/qoder 的日志混在一起,无从区分。
现在:本卡片常驻;条目带 platform 时按当前选中渠道过滤,不带 platform 的
全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
</div>
<div class="card" id="ch-log-card">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<b style="font-size:15px">运行日志</b>
<span class="grow"></span>
<label class="muted" style="font-size:12px;display:inline-flex;align-items:center;gap:5px;white-space:nowrap">
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()" style="margin:0"> 显示例行任务
</label>
<select id="ch-log-level" onchange="chLogRender()"
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
<option value="all">全部级别</option>
<option value="warn">警告及以上</option>
<option value="error">仅错误</option>
</select>
</div>
<div class="muted" style="font-size:12px;margin:8px 0" id="ch-log-hint"></div>
<!-- ★ 文本**允许换行**(word-break),否则长诊断在窄卡片里被截断,
而截断的尾巴正是最该看到的部分(如 HTTP 状态/具体原因)。 -->
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto;word-break:break-word;line-height:1.5"></ul>
</div>
</div>
</div>
<script>
@ -10636,15 +10887,76 @@ plugin.ui = {
}
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
let state = { statuses: [], filter: "all", search: "", selected: undefined,
accounts: undefined, models: undefined, login: null, pollTimer: null };
accounts: undefined, models: undefined, login: null, pollTimer: null,
// 日志:原始条目 + 用户的两个视图开关(级别门槛、是否显示例行任务)
log: [], logLevel: "all", logVerbose: false };
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
// ── 运行日志的渲染(级别门槛 + 例行任务折叠 + 按渠道过滤)──
//
// 三个过滤维度,各自解决一个实测问题:
// ① **级别**:`task` 是定时任务的常规结果。实测 50 条里 48 条(96%)都是它,
// 默认**不显示**,否则真正的用户动作被淹没。warn/error 永远显示。
// ② **按渠道**:条目带 `platform` 时只在对应渠道视图显示。这是因为
// 日志本身是全局的(单一数组),若不加区分,点开 buddy 会看到 trae 的事。
// 不带 platform 的条目(跨平台汇总,如"模型目录已刷新(N 个平台)")
// 只在「全部渠道」视图出现 —— 它们本来就不属于任何单一渠道。
// ③ **同文折叠计数**:后端把紧邻同文合并成一条并给 n>1(见 logEvent),
// 这里显示成 `×N`,既保留"重复了多少次"的信息又不刷屏。
function chLogRender() {
const ul = q("#ch-log"); if (!ul) return;
const verbose = q("#ch-log-verbose");
const sel = q("#ch-log-level");
state.logVerbose = verbose ? verbose.checked : state.logVerbose;
state.logLevel = sel ? sel.value : state.logLevel;
const rank = { info: 1, task: 1, warn: 2, error: 3 };
const minRank = state.logLevel === "error" ? 3 : state.logLevel === "warn" ? 2 : 1;
const selPlat = state.selected;
const rows = (state.log || []).filter((e) => {
const lvl = e.level || "info";
// 「显示例行任务」勾上时,task 视同 info 一并显示
const eff = (lvl === "task" && state.logVerbose) ? "info" : lvl;
if ((rank[eff] || 1) < minRank) return false;
// 渠道过滤:选中某渠道时,只显示归属于它、或没有归属的全局条目?
// 不 —— 选中渠道时应**只**显示该渠道的条目,否则"点 buddy 看到 trae"的
// 归属错误会以另一种形式回来。全局条目留给「全部渠道」视图。
if (selPlat) return e.platform === selPlat;
return true;
});
const hidden = (state.log || []).length - rows.length;
const hint = q("#ch-log-hint");
if (hint) {
const parts = [];
parts.push(selPlat ? ("当前渠道:" + esc(selPlat)) : "全部渠道");
if (!state.logVerbose) parts.push("例行任务已折叠");
if (hidden > 0) parts.push("另有 " + hidden + " 条被过滤");
hint.innerHTML = parts.join(" · ");
}
ul.innerHTML = rows.map((e) => {
const lvl = e.level || "info";
const ts = e.at ? new Date(e.at * 1000).toLocaleString() : "";
const badge = lvl === "error" ? '<b style="color:#e5534b">错误</b> '
: lvl === "warn" ? '<b style="color:#d29922">警告</b> '
: lvl === "task" ? '<span class="muted">例行</span> ' : "";
const n = (e.n || 1) > 1 ? ' <span class="muted">×' + e.n + "</span>" : "";
const del = String(e.msg || "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
return '<li style="margin-bottom:3px">' + badge +
'<span class="muted" style="font-size:11px">' + ts + "</span> " + del + n + "</li>";
}).join("") || '<li class="muted">(无匹配日志)</li>';
}
window.chLogRender = chLogRender;
async function loadStatuses() {
const j = await call("channels/provider/status", "POST", {});
state.statuses = j.statuses || [];
renderSummary(); renderGrid();
const sj = await call("channels/status");
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
state.log = sj.log || [];
chLogRender();
const aj = await call("channels/auto_checkin", "POST", {});
q("#ch-auto").checked = aj.autoCheckin !== false;
}
@ -10775,7 +11087,13 @@ plugin.ui = {
// ---- 动作 ----
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
window.chSelect = async (id) => {
state.selected = id;
await renderDetail();
// 切渠道要重渲染日志:日志条目带 platform,视图按当前选中渠道过滤
// (不重渲染的话,点开 buddy 仍显示上一个渠道的日志)。
chLogRender();
};
window.chAcct = async (aid, act) => {
const id = state.selected; if (!id) return;
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
@ -11114,7 +11432,11 @@ function plugin.__inject_refresh_platform_probe(args)
label = id,
flow = "manual",
adapter = "openai",
base_url = "http://127.0.0.1:1/v1/chat/completions",
base_url = "http://127.0.0.1:1/v1",
-- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint
-- (内核 URL = base_url + endpoint)。测试夹具也照此写,
-- 免得将来有人把这行当范例复制进正式平台。
endpoint = "/chat/completions",
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
models = { "m1" },
hosts = { "127.0.0.1" },
@ -11334,6 +11656,13 @@ function plugin.__resync_sources_probe(args)
return { json = { ok = true, n = n } }
end
-- plugin.__utf8_clip_probe:只给测试用 —— 直接验证 utf8Clip 的语义
-- (截断后仍是合法 UTF-8、未超长时原样返回)。静态断言看不出这些性质。
function plugin.__utf8_clip_probe(args)
args = args or {}
return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } }
end
-- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。
-- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、
-- 不在 exports 里),判据要断言"某件事被记录了"就得有它。