Compare commits

...

6 Commits

Author SHA1 Message Date
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
13 changed files with 1269 additions and 130 deletions

View File

@ -55,3 +55,31 @@ func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
}
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

@ -71,8 +71,8 @@ func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
}
// 这些是异常 —— 必须 warn(不能被一起折叠掉)
for _, pat := range []string{
`logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn")`,
`logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120), "warn")`,
`logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`,
`logEvent("凭据续期失败 " .. #failed .. " 个:`,
} {
if !strings.Contains(s, pat) {
@ -138,3 +138,53 @@ func TestChannelsLogPanelHasFilters(t *testing.T) {
}
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* 覆盖,
// 而我们要的恰恰是"源留下了"这个状态。
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred",
}); err != nil {
t.Fatalf("account_add: %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
t.Fatalf("account_add 报错: %v", j["error"])
}
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred",
}); err != nil {
t.Fatalf("account_add(2): %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
t.Fatalf("account_add(2) 报错: %v", j["error"])
}
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
"name": "my-handmade-source", "base_url": "http://127.0.0.1:1",
"adapter": "openai", "models": []interface{}{"m1"},
}); err != nil {
t.Fatalf("注册手工源: %v", err)
} else if j := jsonOf(t, r); j["error"] != nil {
// ★ 必须看 json 里的 error:内核把失败放在返回体里,
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
t.Fatalf("注册手工源报错: %v", j["error"])
}
// 摘账号(保留源)
for _, id := range []string{"deadbeef1234", "dead-acct-1"} {
if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{
"platform": "buddy", "account_id": id,
}); err != nil {
t.Fatalf("摘账号 %s: %v", id, err)
}
}
// ① dry_run(默认)只报告、不删
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
orphans, _ := j["orphans"].([]interface{})
seen := map[string]bool{}
for _, o := range orphans {
if s, _ := o.(string); s != "" {
seen[s] = true
}
}
for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
if !seen[want] {
t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+
" 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+
" 实得 %v", want, seen)
}
}
if seen["my-handmade-source"] {
t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)")
}
if j["dry_run"] != true {
t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"])
}
if j["removed"].(float64) != 0 {
t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"])
}
// ② 确认 dry_run 真的没删(看源列表)
after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
names := sourceNames(jsonOf(t, after))
if !names["channels-buddy-deadbeef1234"] {
t.Fatalf("dry_run 竟真删了!源列表: %v", names)
}
// 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿)
if !names["my-handmade-source"] {
t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names)
}
// ③ 真删:孤儿消失、手工源存活
res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false})
if err != nil {
t.Fatal(err)
}
j = jsonOf(t, res)
if j["ok"] != true {
t.Fatalf("清理应成功,实得 %v", j)
}
after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
names = sourceNames(jsonOf(t, after))
for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
if names[dead] {
t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names)
}
}
if !names["my-handmade-source"] {
t.Fatalf("★ 误删了运维手工源: %v", names)
}
t.Logf("✓ 孤儿源已清、手工源存活")
}
// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。
func sourceNames(j map[string]interface{}) map[string]bool {
out := map[string]bool{}
if arr, ok := j["sources"].([]interface{}); ok {
for _, x := range arr {
if m, ok := x.(map[string]interface{}); ok {
if s, ok := m["name"].(string); ok {
out[s] = true
}
}
}
}
return out
}

View File

@ -0,0 +1,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

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

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

@ -47,6 +47,58 @@ 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",
}
-- ★★ plugin.adaptersDir():适配器目录 = 插件目录的**兄弟**目录
-- (<data>/plugins 与 <data>/adapters)。由插件目录推导,不猜绝对路径
-- (测试与多实例部署都靠它隔离)。
--
-- ⚠️ 为什么必须放在**插件主体**:适配器运行在**另一个沙箱 VM**,
-- 那里 `__llmsproxy_plugin_dir` 与 `LLMSPROXY_PLUGIN_DIR` **都不存在**、
-- **也没有文件系统访问** ⇒ 在适配器里读任何文件都拿不到东西且**静默降级**
-- (实测:zcode 适配器因此只拿到自己定义的 2 个函数,缺 7 个,
-- 每次调用内核报 "missing transform_response" ⇒ 渠道完全不可用)。
-- 所以凡是要"读兄弟文件"的,一律在插件主体做好再内联进去。
function plugin.adaptersDir()
local pd = __llmsproxy_plugin_dir
if type(pd) ~= "string" or pd == "" then
pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or ""
end
if type(pd) ~= "string" or pd == "" then return nil end
local base = pd:gsub("/+$", "")
local parent = base:match("^(.*)/[^/]+$")
if not parent then return nil end
return parent .. "/adapters"
end
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
-- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。
--
@ -64,7 +116,7 @@ plugin.kernel = {
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__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 +235,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",
@ -435,7 +487,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 快照,那是登录时的区域,
@ -461,7 +517,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,不能用
@ -1132,7 +1191,6 @@ local PLATFORMS = {
}
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
--
@ -1211,7 +1269,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
@ -1257,7 +1315,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
@ -1292,7 +1350,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
@ -1306,7 +1364,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)
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
@ -1353,7 +1411,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`);报错在编译期,
@ -1584,8 +1642,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 "")
@ -1614,12 +1672,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)。
@ -1658,8 +1712,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)
@ -1669,8 +1721,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
@ -1888,7 +1940,6 @@ end
-- 4. 两者都拿不到时**照常发请求**(不带这两个头)—— 保守降级,
-- 纯插件登录、未装桌面端的用户没有该文件,不能让整个积分功能报错。
-- 5. 缓存解析结果:拿不到时也记 null,不反复读盘/反复 spawn。
local QODER_MACHINE_ENVIRONMENT = "3"
local function qoderMachineCache()
plugin.state.qoderMachine = plugin.state.qoderMachine or { done = false }
@ -1965,7 +2016,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
@ -2559,7 +2610,6 @@ end
-- 即便这个文件泄露,也不含任何 token。
--
-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
local STORE_NAME = ".channels-store.json"
-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
@ -2618,7 +2668,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
@ -2673,7 +2723,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()
@ -2709,7 +2759,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")
@ -2772,6 +2822,34 @@ do
if sh then sh:close() end
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 写一条插件事件日志(带级别与渠道归属)。
--
-- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加:
@ -2911,16 +2989,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
@ -3289,7 +3365,23 @@ local function parseRemoteModels(kind, body, provider)
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
@ -3321,6 +3413,8 @@ local function parseRemoteModels(kind, body, provider)
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
@ -3895,7 +3989,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)
@ -4914,7 +5007,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()
@ -6024,8 +6117,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`,刻意的堆保护 ——
@ -6088,11 +6179,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
@ -6243,7 +6334,6 @@ end
-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」
-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池
-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。
local EXPIRING_WINDOW_DAYS_DEFAULT = 15
expiringWindowDays = function()
-- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。
@ -6263,7 +6353,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):
@ -6291,7 +6381,6 @@ end
-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
local BALANCE_CACHE_TTL = 60
-- (余额缓存改用通用 TTL 缓存后,这个助手不再需要)
@ -6307,7 +6396,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)
@ -6447,7 +6536,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 {}
@ -6470,7 +6558,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
@ -6514,7 +6602,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 {}
@ -6532,7 +6619,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
@ -6550,7 +6637,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():定时把远端模型目录拉回来并重同步所有源。
@ -6592,11 +6679,11 @@ function plugin.refresh_models()
if not cok then
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120), "warn")
logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")
elseif cres == true then
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")
elseif cerr then
logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn")
logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")
end
if updated > 0 then
logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")
@ -6663,7 +6750,7 @@ function plugin.refresh_creds()
end
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个", "task") end
if #failed > 0 then
logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300), "warn")
logEvent("凭据续期失败 " .. #failed .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn")
end
end
@ -6672,9 +6759,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
@ -6868,6 +6955,8 @@ plugin.http = {
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
{ path = "channels/reap_orphan_sources", method = "POST", handler = "reap_orphan_sources",
roles = { "admin" }, description = "清理孤儿源(账号已删但源仍在对外服务;默认 dry_run)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
roles = { "admin" }, description = "回收服务端浏览器栈" },
}
@ -8207,7 +8296,6 @@ end
-- 语义:整表替换(与 set_endpoints 的按字段覆盖不同)—— 注入是声明式的
-- “这就是本轮我要用的目录”,避免测试之间残留状态。传空数组即清空。
-- 容量上限:注入属于运维/测试能力,不承担生产目录职责,限 32 条够用。
local INJECT_PLATFORM_LIMIT = 32
-- ============================================================================
-- 服务端浏览器栈的运维面(依赖预检 / 取凭据 / 停栈)
-- ============================================================================
@ -8327,6 +8415,116 @@ end
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
--
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
-- plugin.__add_source_for_test / __forget_account_probe:只给测试用。
--
-- 为什么要它们:孤儿源(账号已删、源留下)是 v2.0.28 事故的**存量形态**,
-- 而正常删除路径(account_delete)会把源一并撤掉 ⇒ 无法用它复现该状态。
-- 两个探针各自只做一件事,且都只在 __ 前缀(不暴露为 HTTP 路由)。
function plugin.__add_source_for_test(args)
args = args or {}
-- ⚠️ 内核 actionAddSource 取的是 args["source"](**嵌套一层**)——
-- 直接平铺字段会被拒("add_source requires a source table")。
-- 实测踩到:我的第一版写平铺,于是源根本没注册,而判据只看到"没有孤儿"。
-- ⚠️ 内核 config.Model 要求 models 是**对象数组** `{id=...}`,
-- 传字符串数组会被拒("source shape is invalid: cannot unmarshal string
-- into Source.models.0")。归一化,免得调用方要懂内核结构。
local ms = {}
for _, m in ipairs(args.models or {}) do
if type(m) == "table" then ms[#ms + 1] = m else ms[#ms + 1] = { id = m } end
end
local r, err = __llmsproxy_kernel.call("add_source", {
source = {
name = args.name, base_url = args.base_url, adapter = args.adapter,
models = ms,
},
})
if r == nil or r.ok ~= true then
return { json = { error = tostring(err or "add_source 失败") } }
end
return { json = { ok = true } }
end
function plugin.__forget_account_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
poolRemove(plat.id, args.account_id)
return { json = { ok = true } }
end
-- reap_orphan_sources:清理**孤儿源** —— 名为 `channels-<平台>-<账号id>`
-- 但账号池里**已无**该账号的源。
--
-- ★ 为什么需要它(真实事故,2026-10-17 于生产实测):
-- v2.0.28 修好了 `remove_source` 漏声明,但**修复之前**凡是走过
-- `account_delete` 的账号,它的源都因为撤源被拒而**留了下来**。
-- 孤儿源没有对应账号 ⇒ 没凭据 ⇒ 每次调用必然失败,但它们**仍在对外服务**:
-- 实测 `channels-buddy-585b335b64cf`(账号早已删除)仍在 config.yaml 里,
-- 独占提供 `hy4-preview-x`,对 6 个共享模型则排在新源**前面**先被尝试 ——
-- 每次都撞 404 再回退(实测每次白付 ~3.1-3.5s 上游往返)。
-- ⇒ 这正是 v2.0.28 那句"账号删了、它的源还在对外服务"的**存量残骸**:
-- 修声明只防新增,**不清理存量**。故需要这个显式一次性入口。
--
-- ★ 安全性(破坏性操作的两条纪律,照 reap_loopbacks 的先例):
-- ① **只删该删的**:名字必须**严格**匹配某个**已知平台**的
-- `channels-<平台>-<账号id>`,且该账号确实不在池中。
-- ⚠️ 不能用 `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的,
-- 会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main"
-- (账号 id 里含 `-` 就错,而 buddy-main 就含)。按已知平台前缀逐个匹配。
-- 运维手工配的源(任何其它名字)一律不碰;旧版单账号源 `channels-<平台>`
-- 也不碰(那是 account_delete 的收尾职责,且可能有意保留作兼容)。
-- ② **dry_run 默认开启**:不带 `dry_run=false` 只报告不删。
function plugin.reap_orphan_sources(req)
-- ⚠️ 两种调用形态都要兜住(本仓库的既有约定,见 account_add 等):
-- * HTTP 路由:参数在 req.body(JSON 字符串)
-- * 插件 Call(测试/内部):参数**直接就是** req,bodyOf 会返回空表
-- ⇒ 只读 bodyOf 会把 `dry_run=false` 静默丢成默认的 true(**实测评据
-- 就是这么失败的**:dry_run 永远是 true,所以"真删"根本没发生)。
local args = bodyOf(req) or {}
if args.dry_run == nil then args = req or {} end
local dry = args.dry_run
if dry == nil then dry = true end
if dry ~= false then dry = true end
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = ls and ls.json or ls
if type(arr) ~= "table" then arr = {} end
local found, removed, failed = {}, 0, {}
for _, x in ipairs(arr) do
local nm = type(x) == "table" and x.name or nil
if type(nm) == "string" then
local plat, acct = nil, nil
for _, p in ipairs(PLATFORMS) do
local pref = "channels-" .. p.id .. "-"
if nm:sub(1, #pref) == pref then
plat, acct = p.id, nm:sub(#pref + 1)
break
end
end
if plat and acct ~= "" and poolGet(plat, acct) == nil then
found[#found + 1] = nm
if not dry then
local r, err = __llmsproxy_kernel.call("remove_source", { name = nm })
if r == nil or r.ok ~= true then
failed[#failed + 1] = nm .. ": " .. tostring(err or "(无错误信息)")
logEvent("孤儿源清理失败 " .. nm .. ": " .. tostring(err or "(无错误信息)"),
"error", plat)
else
removed = removed + 1
logEvent("孤儿源已清理 " .. nm .. "(账号 " .. acct .. " 已不在池中)", "warn", plat)
end
end
end
end
end
return { status = 200, json = {
ok = #failed == 0, dry_run = dry,
orphan_count = #found, orphans = found,
removed = removed, failed = failed,
} }
end
function plugin.reap_loopbacks(req)
local n = 0
for sid in pairs(plugin.state.loopbacks or {}) do
@ -8367,8 +8565,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 = {}
@ -9203,7 +9401,6 @@ end
-- isAutoCheckinExcluded):代价发生在调用期间(拉 headful Chromium +
-- 阿里云按设备限流 150 次/小时),"等它报错再判定"来不及。
-- * 用户**手动**点领取时带上 captcha param 即可走通(面板交互)。
local ZCODE_ORIGIN = "https://zcode.z.ai"
-- zcodeHeaders:照参考 buildZcodeHeaders。X-Device-Mid 是**硬需求**
-- (缺它 billing 全家桶回 400)。
@ -9227,7 +9424,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",
@ -9257,7 +9454,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") }),
})
@ -9286,7 +9483,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" },
@ -10138,38 +10335,23 @@ local EXPECTED_MIN_CHARS = 2800
-- ============================================================================
-- 复用 anthropic 适配器
-- ============================================================================
-- 适配器目录 = 插件目录的**兄弟**目录(<data>/plugins 与 <data>/adapters)。
-- 由插件目录推导,不猜绝对路径(测试与多实例部署都靠它隔离)。
local function adaptersDir()
local pd = __llmsproxy_plugin_dir
if type(pd) ~= "string" or pd == "" then
pd = (os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")) or ""
end
if type(pd) ~= "string" or pd == "" then return nil end
local base = pd:gsub("/+$", "")
local parent = base:match("^(.*)/[^/]+$")
if not parent then return nil end
return parent .. "/adapters"
end
-- loadAnthropic():在本地环境里执行内核的 anthropic 适配器源码,拿回它的表。
local function loadAnthropic()
local dir = adaptersDir()
if not dir then return nil, "无法推导适配器目录" end
local f = io.open(dir .. "/anthropic.lua", "r")
if not f then return nil, "读不到 anthropic.lua(" .. dir .. ")" end
local src = f:read("*a")
f:close()
local chunk, lerr = load(src, "anthropic.lua")
if not chunk then return nil, "anthropic.lua 无法编译: " .. tostring(lerr) end
-- ★★ 基座由**插件主体内联**进来(见下方 §基座占位符),
-- **不能**在适配器里自己读文件。实测(2026-10-16):
-- 适配器运行在**另一个沙箱 VM**,那里 `__llmsproxy_plugin_dir` 与
-- `LLMSPROXY_PLUGIN_DIR` **都不存在**,**也没有文件系统访问**
-- ⇒ 原先那段"就地读 anthropic.lua"必然失败且**静默降级**:
-- 适配器照常装载,但只有它自己定义的 2 个函数,
-- 缺 transform_response / transform_stream_chunk 等 **7 个**。
-- ⇒ 后果:每次调用内核对缺失函数**响亮报错**(不是静默透传)
-- ⇒ 流式请求逐帧报错 ⇒ 该渠道**完全不可用**。
-- 该缺陷一直潜伏:zcode 从来没有凭据 ⇒ 源未注册 ⇒ 从未被调用过。
local BASE = (function()
local chunk, lerr = load(__INLINE_ANTHROPIC_SRC__, "anthropic-base.lua")
if not chunk then return nil, lerr end
local ok, base = pcall(chunk)
if not ok or type(base) ~= "table" then
return nil, "anthropic.lua 执行失败: " .. tostring(base)
end
return base, nil
end
local BASE, BASE_ERR = loadAnthropic()
if not ok or type(base) ~= "table" then return nil, tostring(base) end
return base
end)()
if BASE then
for k, v in pairs(BASE) do
if adapter[k] == nil then adapter[k] = v end
@ -10554,8 +10736,18 @@ end
-- system 缺官方身份块结构直接 405 + 3012(实测),与 HTTP 头/版本/频率全无关。
-- 通用 anthropic 适配器不注入身份块 ⇒ 即使拿到 zcode 账号也**必然**被拒。
-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。
local function installZcodeAdapter()
-- dirOverride 仅用于测试/诊断:强制指定适配器目录,让"基座不可得"
-- 成为**安装期可观测**的失败(否则它只会在运行期逐帧炸,且原因指向内核)。
local function installZcodeAdapter(dirOverride)
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
-- ★ 读到 anthropic 基座源码并在**插件主体**内联进适配器源码。
-- ⚠ 不能在适配器里自己读文件:适配器沙箱没有 fs、也没有 plugin_dir,
-- 那样会**静默降级**成"只有自己定义的 2 个函数"(见适配器顶部注释)。
local adir = dirOverride or plugin.adaptersDir()
if not adir then return false, "无法推导适配器目录" end
local af = io.open(adir .. "/anthropic.lua", "r")
if not af then return false, "读不到 anthropic.lua(" .. adir .. ")" end
local asrc = af:read("*a"); af:close()
-- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。
-- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)——
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
@ -10564,6 +10756,13 @@ local function installZcodeAdapter()
-- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
"\nend)()\n" .. ZCODE_ADAPTER_SRC
-- ⚠ 用**纯字符串替换**而不是 gsub:gsub 的替换串里 `%` 有特殊含义
-- (`%1` 是捕获引用),而适配器源码含 `%s`/`%d` 等格式化串 ⇒ 会破坏语法。
-- ⚠ 占位符 token 刻意在注释里**不写字面量**,否则 find 会先命中注释。
local ph = "__INLINE_ANTHROPIC_SRC__"
local qi = full:find(ph, 1, true)
if not qi then return false, "适配器源码缺少基座占位符" end
full = full:sub(1, qi - 1) .. string.format("%q", asrc) .. full:sub(qi + #ph)
local f, lerr = load(full)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
@ -10789,21 +10988,23 @@ plugin.ui = {
全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
</div>
<div class="card" id="ch-log-card">
<h2 style="margin:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<span>运行日志</span>
<span class="grow"></span>
<label class="muted" style="font-size:12px;font-weight:400;display:flex;align-items:center;gap:5px">
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()"> 显示例行任务
</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>
</h2>
<div class="muted" style="font-size:12px;margin:6px 0 8px" id="ch-log-hint"></div>
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto"></ul>
<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>
@ -11591,6 +11792,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 里),判据要断言"某件事被记录了"就得有它。
@ -11672,13 +11880,21 @@ end
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
function plugin.__adapter_install_probe()
function plugin.__adapter_install_probe(args)
args = args or {}
-- 带 adapters_dir 时**重新安装** zcode:用来验证「基座不可得 ⇒ 安装期报错」。
-- 之所以要这个缝隙:基座现在由**插件主体内联**(适配器沙箱无 fs),
-- 安装失败若不在此暴露,就只会在运行期逐帧炸,且错误指向内核而非配置。
if args.adapters_dir then
local ok, err = installZcodeAdapter(args.adapters_dir)
return { json = { zcode_reinstall = { ok = ok and true or false, err = err } } }
end
return { json = {
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
zcode = plugin._zcode_adapter,
} }
codearts = plugin._codearts_adapter,
trae = plugin._trae_adapter,
gemini = plugin._gemini_adapter,
zcode = plugin._zcode_adapter,
} }
end
-- plugin.__sync_source_probe:只给测试/诊断用 —— 手动触发一次源同步并
@ -11778,4 +11994,6 @@ do
if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end
end
return plugin

View File

@ -1459,8 +1459,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)
}