Compare commits

...

5 Commits

Author SHA1 Message Date
8109acf7cd feat(trae): batch 模型目录(含参考记录的两个真实缺陷修复 + 元数据)
我们此前 trae 的模型列表是**纯硬编码**(32 项名字),既没有真实元数据,也没打
batch 端点。参考的 parseTraeBatchModelList 记录了**两个已修的缺陷**,移植时
必须一起带上,否则等于把 bug 再引进一遍:

## ① 通道白名单是「准入」而不只是「排序」(issue IKJOZ7)
22 个 function 里有一半在本插件推理端点下**不可调用**(实测 chat→4023
model unknown、builder→4001、inline_chat→3003)。只把白名单用于"同模型择优"
的旧写法 = "目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,而错误文案
指向**模型**,极易被误判成"这个模型坏了"。现在不在表内的通道**整组丢弃**。

## ② 空档位不得覆盖有档位(issue IKI7WT/IKILR7)
旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据**相反**
—— 上游把空档位的 solo_work_lite/solo_design_remote 排在**最后**,实测 13 个
模型因此丢掉 reasoning_effort_config、全部显示"未提供推理等级"。
合并规则(与参考逐条对齐):空档位不得覆盖有档位;两侧都有档位时按通道优先级
取更靠前者;其余保持"后覆盖前"。

## ③ is_custom_model 是「仅可见但不可调用」的权威判据
该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。
⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志随服务端下发变化,
曾据名字把 qwen3.8-flash 误记为应剔除(它其实正常可用)。

## 附带:远端目录**元数据**不再丢
原来目录只回 id ⇒ 参考能提供的上下文窗口/输出上限/思考档位/消耗倍率全丢。
现按参考存下完整条目并在 model_list 以 `remote` 字段报出。
⚠ contextWindow 取 `context_window_tokens.dev` 而非 `max`:max 需开启
display_config.max_mode 才可用(我们不实现该开关),采信 max 会让上层以为有
1M 窗口、实际被上游拒。

## 基础设施
- refreshRemoteModels 支持 ep.body(POST 型目录端点)与 ep.sign(复用平台既有
  的头部构造,不另写一套 —— 否则"chat 能通、目录 401"极难查)。
- trae 走 sign="trae_solo" 复用 traeCheckinHeaders(Cloud-IDE-JWT + 约 20 个
  客户端头;参考实测简化头会被拒)。
- 新增 __trae_entry_probe(诊断"合并规则选中了哪条"),此前只能靠猜。

## 判据(3 条 + 变异 3/3)
internal/core/channels_trae_catalog_test.go
- 白名单准入 + 档位来源(空档位不得覆盖有档位)
- 四条硬性过滤(usage/config_switch/is_invisible_to_user/is_custom_model)
  且 **is_custom_model=false 的 custom_model_* 条目必须保留**(判据是标志值)
- 请求体必须传全部 22 个 function(与真实 IDE 一致;只传聊天通道会让非聊天
  通道条目位置错乱甚至被跳过)
变异:白名单不做准入 → 报红;无条件后覆盖前 → 报红;不过滤 custom → 报红。

★ 调试中发现并修掉我自己写的一个**静默失效**:
`declaresEffort` 读 `e.reasoning_effort_config` 而 `parseEntry` 存的是
`e.reasoning` ⇒ 判定恒为 false ⇒ 规则①永不生效(正是它要防的缺陷)。
判据当场抓到。**教训:跨函数用同一份数据时,字段名必须对齐 —— 这类不一致
不会报错,只会让规则静默失效。**

验证:internal/core 369s 全绿。
2026-10-16 08:28:36 +08:00
7c82c7ff6b fix(channels): account_clear_credentialless 尊重 dry_run(args=req 回退吃掉 body)
## 真实缺陷(我自己在上一个提交里引入,部署后实测踩到)
handler 开头照其它 18 处写了 `if args.platform == nil then args = req end`。
但本函数**不传 platform 是合法用法**(清全部平台)⇒ args.platform 自然为 nil
⇒ args 被换成 HandlerRequest ⇒ 刚解出来的 dry_run 就丢了 ⇒
**传 dry_run=false 却被当成未传,静默只预演**。

破坏性操作里这是最糟的形态:操作者以为执行了、实际没执行(反过来同样危险)。
实测:不带 platform 时 deleted 恒为 nil,池子一个没动。

## 修法
兜底只认「req 本身就是参数表」这种形态(`req.body == nil and req.query == nil`),
不再用「某字段为 nil」当判据 —— 那个字段为 nil 可能只是本次调用**合法地没用它**。

## ★ 判据为什么第一版没咬住
我原来的夹具用 **map body** 调 Call,而缺陷只在**字符串 body**(HTTP 真实路径)
上暴露 —— `bodyOf` 对 table 直接返回,根本走不到回退那行。
补 `TestAccountClearCredentiallessRespectsDryRunViaStringBody`(用
`InvokeHandler` + 字符串 body + 不传 platform)后变异才咬住。
**教训:夹具的调用形态必须与生产一致 —— 用 map 便捷调用会绕过 body 解析路径。**

判据 4 条(含本条)+ 变异 3/3。
2026-10-16 07:51:21 +08:00
1f18643d1f feat(channels): 无凭据账号的批量清理能力(dry_run 默认开启)
## 为什么需要
`account_add` 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
于是"先建占位、再走登录补齐"是正常用法。但登录没走完时(用户放弃、或历史批量
导入)会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
(面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"难以判断。

实测生产:13 个账号里只有 1 个真正可用 —— codearts 10 个 + trae 2 个全是占位
(同秒批量创建、nickname=id、凭据库里无对应条目)。

## 安全性(破坏性操作,两条性质都被变异验证钉住)
① **只删没有凭据的**:有凭据的一律不动(哪怕它测试失败 —— 那是"可疑"而非
   "空壳",处置完全不同);
② **dry_run 默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
③ 复用 `plugin.account_delete`(同一份删除实现)—— 删除时要顺带做的事
   (drop source、清旧单账号源、删凭据)不另写一套,否则必然漂移。

## ★ 踩到 Lua 顶层 local 上限(第 13 次同类)
helper 写成顶层 `local function accountHasCredential` 后 `luac -p` 直接报
`too many local variables (limit is 200)`。改为挂在 plugin 表上(`plugin.__hasCredential`)
—— 同样只有一份实现,且不占顶层名额。**每次给本文件加顶层 local 前必须 luac -p。**

## 判据(3 条 + 变异 2/2)
internal/core/channels_clear_credentialless_test.go
- 只删空壳、保留有凭据的,且**有凭据者的凭据必须仍在**(防误删)
- 指定 platform 时范围隔离(不动别的平台)
- 未知平台必须报错(否则会误删全部平台)
变异:无条件全删 → 报红;dry_run 默认改 false → 报红。
2026-10-16 07:45:52 +08:00
e6fa2a678f feat(zcode): 3012 判据/文案/诊断模块(照参考逐行移植,含对抗性审计反例)
照参考 zcode-diagnostics.ts 移植,作为**两处消费者共用的一份源码文本**:
① 插件主体(领取路径 claimZcode);② channels-zcode 适配器(推理路径)。
共用是必须的:3012 有**账号冷却惩罚**(30 分钟起步,反复触发升级到 24 小时乃至
停用),"请勿连续重试"这句话绝不能只在一条路径上(参考为此专门抽成单一份)。

## 为什么这次移植的不是"锦上添花"
我们原来的领取路径是 `code == 3012 or msg:find("unusual activity")`,而参考用
**对抗性审计**打出了两个方向的真实缺陷(2026-10-05,实测记录在源码注释里):

**误判方向**(把限流当风控):两者用户动作**完全相反**(稍后重试 vs 别重试),
且领取路径据此**短路**、跳过本可领取的 plan。实测反例:
  `too many requests, retry after 3012 ms`        ← 毫秒数被当风控
  `<h1>Error 3012</h1>upstream connect timeout`   ← HTML 里的数字
  `rate limited: request blocked, retry after 3012 ms`  ← block 距 14 字符
  `temporarily blocked; see error 3012`                 ← block 距 12 字符
  `plan-3012-trust` / `trust_3012` / `x3012y` / `13012` ← 标识符形态

**漏判方向**(真风控被放过):上游 3012 的 msg 原文
`request has been blocked due to unusual activity.` **不含任何数字**(数字在 code
字段里)⇒ "必须有独立词 3012"这条门禁会反过来把它漏掉。

⇒ 判据以**语义短语**为主、数字只作兜底且必须与 block **紧邻(≤10 字符)**;
词边界集必须含 `-`/`_`(参考两版才收全:先只排数字 ⇒ plan-3012 仍命中;
再补字母 ⇒ 仍命中,因连字符不属 [0-9A-Za-z])。
另:业务码认**纯数字字符串**(上游回 {"code":"3012"} 时,只认 number 会让风控码
整条丢失 ⇒ 用户看到那句裸上游串)。

## 一并移植的其它实测教训
- `formatGap`:分钟档必须**逐档单独算**。参考初版写 `floor(seconds % 60)`,
  90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)——该错误是在真实探针
  输出里发现的,纯函数单测当时没覆盖 ≥60 秒区间。
- 诊断的「距上条」必须用**单独存的 previousSentAt**,不能拿 lastSentAt 顶替
  (调用顺序是"先记本次、再诊断",顶替会让它恒为 0ms、永远没有信息量)。
- 形态诊断必须读**真正要发出去的那个对象**,不读常量:常量只能证明"源码里写了
  多少字",证明不了"线上发出去多少字";空 system 时必须**如实显示 0**。
- 诊断行**绝不写凭据**:账号只以进程内自增序号出现。

## ★ 接线上的一个真实 bug(我自己引入并自查出来)
`installZcodeAdapter` 最初把 DIAG 源码直接 `.. ZCODE_ADAPTER_SRC` 拼接 ——
而 DIAG 末尾是 `return Z`,直接拼会让那个 return 结束**整个 chunk** ⇒ 适配器
代码永不执行,且**装载会静默成功**("加载成功但适配器不存在")。
修法:包进 `(function() ... end)()` 让 return 只结束该函数。

## 判据
`internal/core/channels_zcode_3012_test.go`(走共用探针打**那一份**实现):
- 19 条分类矩阵(含上列全部审计反例)
- formatGap 逐档(含 90000ms 必为「1分30秒」)
- 诊断行凭据泄漏守卫(账号 id 的每个 ≥4 字符子串都不得出现)
- 文案共用 + 必含「请勿连续重试」+ 空参不留空壳
- 形态诊断读实际对象(空 system 必须显示 0)

★ 变异 2/2 咬住。其中分类矩阵**第一版没咬住** —— 因为"紧邻检查"独立挡住了
那些用例,从未**隔离**词边界规则;补 5 个"block 紧邻但 3012 在标识符里"的
用例后才有效。教训:**必须有一组用例只被被测规则区分**,否则别的守卫会把它
的失效掩盖掉(本轮第 4 次"判据太弱")。

验证:internal/core 351s 全绿。
2026-10-16 01:38:34 +08:00
b871c0c713 fix(zcode): 日期块对齐参考的两条规则(幂等 + 只看第一条),修 off-by-one
通读参考 zcode-identity.ts 的 withContextPrefix 后发现我的实现漏了两条官方行为,
且其中一条藏着真实 bug:

## ① ★ 幂等判断 off-by-one(真 bug)
`"<system-reminder>"` 是 **17** 个字符,而我写的是 `c:sub(1, 16) == SR`
—— 判断**永远为假** ⇒ 幂等完全失效:同一请求重试/多次 transform 时日期块
会叠加成多层,改变 content 的前缀结构(上游检查的正是这个结构)。
修法:用 `#SR` 算长度,不写死数字。

## ② 只看**第一条**消息,且它必须 role == "user"
我原来实现成「往后扫第一个 user 消息」。官方行为是:只处理 `messages[0]`,
且若它不是 user 就**完全不动** —— 不是去找后面那条 user。已对齐。

## ③ 其它类型兜底
参考对非字符串/非数组的 content 也会置为 `[prefix]`,照做。

判据 2 条(新增):
- TestZcodeContextPrefixIsIdempotent     已含日期块时不重复插
- TestZcodeContextPrefixOnlyTouchesFirstMessage  首条非 user 时不动、首条 user 时插入
变异 2/2 咬住:把 #SR 改回写死的 16 → 报红;去掉幂等判断 → 报红。

★ 我自己在写这条判据时又犯了一个错并当场自查出来:断言「文本 content 应保持
字符串」,而 anthropic 基座把文本转成块数组是**正常**的协议转换行为 —— 差点把
正确的实现判成错。判据要验的是「**日期块**有没有被插进来」这个事实,不是
content 的容器类型。

验证:internal/core 335s + internal/gateway 207s 全绿。
2026-10-16 01:13:11 +08:00
5 changed files with 1718 additions and 16 deletions

View File

@ -0,0 +1,235 @@
package core
import (
"testing"
"llmsproxy/internal/lua"
)
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
//
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
// (codearts 10 + trae 2 全是占位)。
//
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
h := newAccountCleanupHarness(t)
// ① 默认 dry_run:必须**不删**任何东西
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
preJ := jsonOf(t, pre)
if preJ["dry_run"] != true {
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
}
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
if got := len(accountIDs(t, h, "codearts")); got != 4 {
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
}
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
}
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
}
// ② 真执行:删无凭据的,**保留有凭据的**
if _, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"dry_run": false}); err != nil {
t.Fatal(err)
}
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
}
// ★ 关键:留下的必须是**有凭据的那个**
left := accountIDs(t, h, "codearts")
if len(left) != 1 || left[0] != "withcred" {
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
}
// 有凭据的账号其凭据也必须仍在(不能被误删)
if !h.hasCredential("withcred") {
t.Fatal("有凭据账号的凭据被误删了")
}
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
}
// 指定平台时只动那个平台,别的不碰。
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
h := newAccountCleanupHarness(t)
if _, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
t.Fatal(err)
}
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("codearts 应剩 1,实得 %d", got)
}
if got := len(accountIDs(t, h, "trae")); got != 2 {
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
}
t.Log("✓ 平台范围隔离正确")
}
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
h := newAccountCleanupHarness(t)
res, err := h.ps.Call("channels", "account_clear_credentialless",
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
if j["error"] == nil {
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
}
// 且什么都没删
if got := len(accountIDs(t, h, "codearts")); got != 4 {
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
}
}
// ── harness ──
type accountCleanupHarness struct {
t *testing.T
ps *lua.Plugins
}
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
t.Helper()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
h := &accountCleanupHarness{t: t, ps: ps}
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
for _, id := range []string{"empty1", "empty2", "empty3"} {
h.add("codearts", id, "")
}
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
h.add("trae", "tempty1", "")
h.add("trae", "tempty2", "")
return h
}
func (h *accountCleanupHarness) add(platform, id, cred string) {
h.t.Helper()
args := map[string]interface{}{"platform": platform, "account_id": id}
if cred != "" {
args["credential"] = cred
}
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
}
}
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "account_list",
map[string]interface{}{"platform": "codearts"})
if err != nil {
h.t.Fatal(err)
}
for _, a := range accountsOf(h.t, res) {
if a["id"] == accountID {
return a["has_credential"] == true
}
}
return false
}
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
t.Helper()
m, ok := got.(map[string]interface{})
if !ok {
t.Fatalf("返回值不是对象: %#v", got)
}
j, ok := m["json"].(map[string]interface{})
if !ok {
t.Fatalf("返回值缺 json 层: %#v", m)
}
return j
}
// accountsOf 取 account_list 返回的账号数组。
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
t.Helper()
j := jsonOf(t, got)
arr, _ := j["accounts"].([]interface{})
out := make([]map[string]interface{}, 0, len(arr))
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
// accountIDs 列某平台的账号 id。
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
t.Helper()
res, err := h.ps.Call("channels", "account_list",
map[string]interface{}{"platform": platform})
if err != nil {
t.Fatalf("account_list(%s): %v", platform, err)
}
var out []string
for _, a := range accountsOf(t, res) {
if id, ok := a["id"].(string); ok {
out = append(out, id)
}
}
return out
}
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
//
// 反例(实测踩到,我自己写的):照其它 handler 写
//
// `if args.platform == nil then args = req end`
//
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
//
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
h := newAccountCleanupHarness(t)
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
if err != nil {
t.Fatal(err)
}
if res.Status != 200 {
t.Fatalf("status=%d body=%v", res.Status, res.Body)
}
j, _ := res.JSON.(map[string]interface{})
if j["dry_run"] == true {
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
}
if j["deleted"] == nil {
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
}
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
if got := len(accountIDs(t, h, "codearts")); got != 1 {
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
}
if got := len(accountIDs(t, h, "trae")); got != 0 {
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
}
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
}

View File

@ -0,0 +1,230 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
// (参考 parseTraeBatchModelList 的注释记录了它们)。
//
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
//
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
//
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
//
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
// reasoning_effort_config,全部显示"未提供推理等级"。
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
var mu sync.Mutex
var gotBody string
h := newTraeCatalogHarness(t, func(body string) {
mu.Lock()
gotBody = body
mu.Unlock()
})
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
if strings.Contains(joined, "only-in-bad-channel") {
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
"否则用户选中它必然失败且错误指向模型:%v", ids)
}
// 但通用通道里的模型要在
if !strings.Contains(joined, "glm-5.2") {
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
}
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
if !h.modelHasReasoningOptions("glm-5.2") {
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
}
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
mu.Lock()
b := gotBody
mu.Unlock()
if b == "" {
t.Fatal("未捕获到目录请求体")
}
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
if !strings.Contains(b, want) {
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
}
}
}
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
h := newTraeCatalogHarness(t, nil)
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
if strings.Contains(joined, bad) {
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
}
}
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
}
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
if !strings.Contains(joined, "no-function-model") {
t.Errorf("合法组内的条目不该被丢:%v", ids)
}
t.Logf("✓ 过滤后目录: %v", ids)
}
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
type traeCatalogHarness struct {
t *testing.T
ps *lua.Plugins
}
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if onBody != nil {
onBody(string(b))
}
w.Header().Set("Content-Type", "application/json")
// 三个通道:
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
fmt.Fprint(w, `{"function_configs":[`+
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
`{"function":"solo_agent","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":true}},`+
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
// (参考点名:判据是标志的值,不是模型名)
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":false}}`+
`]},`+
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
`{"function":"solo_work_lite","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
`]},`+
// ③ 白名单外通道:整组必须被丢弃
`{"function":"inline_chat","config_info_list":[`+
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
`]}`+
`]}`)
}))
t.Cleanup(ts.Close)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
h := &traeCatalogHarness{t: t, ps: ps}
// 把目录端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 塞一个 TRAE 账号(目录请求需要凭据)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "trae",
"account_id": "cat-trae",
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
}); err != nil {
t.Fatal(err)
}
return h
}
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
t.Helper()
if _, err := h.ps.Call("channels", "model_refresh",
map[string]interface{}{"platform": platform}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
var out []string
arr, _ := j["models"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if id, ok := m["id"].(string); ok {
out = append(out, id)
}
}
}
return out
}
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
arr, _ := j["models"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
if m["id"] != modelID {
continue
}
// 档位的真实出口:model_list 把远端目录解出的档位放在
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
return true
}
}
if rm, ok := m["remote"].(map[string]interface{}); ok {
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
return true
}
}
}
return false
}

View File

@ -0,0 +1,285 @@
package core
import (
"encoding/json"
"strings"
"testing"
)
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
// **完全相反**(稍后重试 vs 别重试)。
//
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
// (2026-10-05 自审 + 审计打出的真实缺陷)。
//
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
func TestZcode3012ClassificationMatrix(t *testing.T) {
h := newZcodeIdentityHarness(t)
cases := []struct {
name string
body string
want bool
}{
// ── 真风控(必须命中)──
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
// ── 必须**不**命中(误判会让用户做错事)──
{"13012 不是 3012", `{"code":13012}`, false},
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
{"标识符 trust_3012", `trust_3012`, false},
{"字母包裹 x3012y", `x3012y`, false},
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
{"block 距 12", `temporarily blocked; see error 3012`, false},
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
{"标识符+block 紧邻", `plan-3012 blocked`, false},
{"字母包裹+block 紧邻", `x3012 blocked`, false},
{"下划线+block 紧邻", `trust_3012 blocked`, false},
{"独立词+block 紧邻", `3012 blocked`, true},
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := h.callZD("hasUnusualActivity", tc.body)
if got != tc.want {
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
}
})
}
}
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
h := newZcodeIdentityHarness(t)
for _, tc := range []struct {
ms int
want string
}{
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
{59000, "59.0s"}, // 不足一分钟
{60000, "1分"}, // 整分钟
{3600000, "1小时"}, // 整小时
{3900000, "1小时5分"}, // 小时+分
{500, "500ms"},
} {
got := h.callZDString("formatGap", tc.ms)
if got != tc.want {
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
}
}
}
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
//
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
// 文案里」来钉死这条 —— 不是"看起来没有"。
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
h := newZcodeIdentityHarness(t)
const secretID = "zcode-abcdef1234567890-SECRET"
h.callZDAction("accountOrdinal", secretID)
h.callZDAction2("noteRequestSent", secretID, 1000)
h.callZDAction2("noteRequestOk", secretID, 1100)
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
"accountId": secretID,
"sentAt": 1200,
"now": 1300,
"status": 405,
})
if diag == "" {
t.Fatal("formatDiagnostic 返回空")
}
// 账号 id 的每个 >=4 字符子串都不得出现
for i := 0; i+4 <= len(secretID); i++ {
if strings.Contains(diag, secretID[i:i+4]) {
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
}
}
if !strings.Contains(diag, "账号#") {
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
}
t.Logf("✓ 诊断行无凭据片段: %s", diag)
}
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
//
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
h := newZcodeIdentityHarness(t)
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
"subject": "领取 plan-x",
"rawResponse": "request has been blocked",
})
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
if !strings.Contains(msg, want) {
t.Fatalf("风控文案缺 %q:%s", want, msg)
}
}
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
t.Fatalf("空参数不应留下空壳段:%q", bare)
}
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
}
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
//
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := jsonUnmarshal(out, &req); err != nil {
t.Fatal(err)
}
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
if shape == nil {
t.Fatal("describeRequestShape 返回空")
}
chars, _ := shape["identityChars"].(float64)
if chars < 2800 {
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
}
if shape["hasDateBlock"] != true {
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
}
if shape["firstRole"] != "user" {
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
}
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
if c, _ := empty["identityChars"].(float64); c != 0 {
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
}
}
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
//
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
v, ok := m["result"].(bool)
if !ok {
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
}
return v
}
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
h.t.Helper()
var res interface{}
var err error
if opts, ok := a.(map[string]interface{}); ok {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "opts": opts})
} else if fn == "formatGap" {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
s, _ := m["result"].(string)
return s
}
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
h.t.Helper()
var res interface{}
var err error
if len(args) == 2 {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": args[0]})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
out, _ := m["result"].(map[string]interface{})
return out
}
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
func jsonUnmarshal(s string, v interface{}) error {
return json.Unmarshal([]byte(s), v)
}

View File

@ -294,3 +294,111 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
}
t.Logf("✓ 基座缺失时响亮失败: %v", err)
}
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
//
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
in := `{"model":"m","messages":[{"role":"user","content":[` +
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
`{"type":"text","text":"hi"}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content := msgs[0].(map[string]interface{})["content"].([]interface{})
n := 0
for _, c := range content {
if b, ok := c.(map[string]interface{}); ok {
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
n++
}
}
}
if n != 1 {
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
}
}
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
//
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("首条不是 user 时不动", func(t *testing.T) {
in := `{"model":"m","messages":[` +
`{"role":"assistant","content":"opening"},` +
`{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
noReminder := func(content interface{}) bool {
switch c := content.(type) {
case string:
return !strings.Contains(c, "<system-reminder>")
case []interface{}:
for _, e := range c {
if b, ok := e.(map[string]interface{}); ok {
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
return false
}
}
}
}
return true
}
// 首条(assistant)不该被插日期块
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
}
// 后面的 user 也**不该**被插(官方不往后找)
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
}
// 且原文本内容必须保留(转换不能丢内容)
b, _ := json.Marshal(msgs[0])
if !strings.Contains(string(b), "opening") {
t.Fatal("首条 assistant 的内容丢了")
}
})
t.Run("首条是 user 时插入", func(t *testing.T) {
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
if !ok {
t.Fatal("首条 user 的 content 应升级为块数组")
}
b0 := content[0].(map[string]interface{})
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
}
})
}

View File

@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "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_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", "__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_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", "__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" }
@ -128,6 +128,41 @@ local PLATFORMS = {
},
},
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
-- ★ 远端模型目录:POST /api/ide/v1/batch_get_detail_param(通过 models_remote)。
--
-- 为什么要有它:**只有这个端点能告诉我们每个模型的真实元数据** ——
-- 上下文窗口、输出上限、是否支持图像/工具响应图像、以及**思考档位**。
-- 硬编码的 models 兜底表只有名字,于是 UI 上所有模型都显示"未提供推理等级",
-- 且多模态能力无从得知。
--
-- ⚠ 端点选择:**必须用 batch**(多通道)而不是单通道 get_detail_param。
-- 真实 CN IDE 用 batch 一次查 22 个 function,每个 function 各自一套目录;
-- 只传聊天通道会让非聊天通道的条目在响应里**位置错乱甚至被跳过**(参考实测)。
--
-- ⚠ 请求体必须传**全部 22 个 function**(与真实 IDE 一致),不是只传我们关心的
-- 那几个 —— 理由同上:响应分组与请求顺序相关。解析侧再用白名单做**准入**。
models_remote = {
endpoints = { {
url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param",
method = "POST",
body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",'
.. '"builder_v3","builder","chat","inline_chat","git_ai",'
.. '"custom_agent_generation","utils","code_reviewer","code_review_summary",'
.. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",'
.. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",'
.. '"system_diagnosis"],"agent_type":"",'
.. '"current_config_info":{"config_name":"","is_custom_model":false},'
.. '"mode_type":0,"access_type":0,"ab_force_vids":"",'
.. '"ab_autotest_advanced_mode":0,"show_custom_model":true}',
-- ★ 鉴权方案是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的
-- 设备身份),**不能只发 Bearer** —— 参考注释点明简化头实测失败。
-- 走 sign="trae_solo"(复用平台既有的 traeCheckinHeaders 实现,
-- 与 codearts 的 ep.sign 同一模式,不另写一套头部构造)。
sign = "trae_solo",
} },
auth = "none", -- 鉴权头由 headers_fn 负责
parse = "trae_batch",
},
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
base_url = "https://trae-api-cn.mchost.guru",
@ -3139,6 +3174,219 @@ local function parseRemoteModels(kind, body, provider)
end
end
elseif kind == "trae_batch" then
-- ★ TRAE 的 batch_get_detail_param(多通道模型目录)。
--
-- 参考实现(parseTraeBatchModelList)记录了**三个真实缺陷**,移植时必须一起
-- 带上,否则等于把已修好的 bug 再引进一遍:
--
-- ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
-- 22 个 function 里有一半在本插件推理端点下**不可调用**
-- (实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。
-- 旧实现只把白名单用于"同模型择优",于是"目录声明了什么就发什么" ——
-- 用户选中那些模型必然失败,而错误文案指向**模型**,极易被误判成
-- "这个模型坏了"。现在不在表内的通道**整组丢弃**。
--
-- ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
-- 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据
-- **相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在最后,
-- 实测 13 个模型因此丢掉 reasoning_effort_config,全部显示"未提供推理等级"。
--
-- ③ **is_custom_model 是「仅可见但不可调用」的权威判据**:
-- 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。
-- ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志会随服务端下发变化
-- (某次快照里 5 个条目全部失效、全目录 true 的条目数为 0)。
--
-- 另有硬性过滤:usage 必须 chat_completion、config_switch 不得 false、
-- is_invisible_to_user 不得 true。
local TRAE_CHANNELS = {
"solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote",
"solo_agent_lite",
"chat_v3", "builder_v3", "solo_coder", "solo_design_lite",
"solo_design_remote", "git_ai", "code_reviewer", "code_review_summary",
"multimodal", "system_diagnosis",
}
local rankOf = {}
for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end
-- 取布尔字段(两种大小写)
local function rbool(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return v
end
local function rstr(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return type(v) == "string" and v or nil
end
local function rnum(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return tonumber(v)
end
-- declaresEffort(entry):该条目**能否真正声明出**思考档位。
-- ⚠ 必须与适配器的档位判定完全一致(配置存在 + 未显式 support_thinking:false
-- + options 非空)。只判"配置存在"不够:{support_thinking:false, options:[]}
-- 与 {...options:['high']} 都"存在配置",但前者在 UI 上仍显示"未提供推理等级"
-- —— 按"存在即优先"合并等于没修。
local function declaresEffort(e)
-- ⚠ 字段名必须与 parseEntry 存的一致(e.reasoning)。写成 e.reasoning_effort_config
-- 会让整个判定**恒为 false** ⇒ 规则①永不生效 ⇒ 空档位照样覆盖有档位
-- (正是该规则要防的缺陷;判据当场抓到)。
local rc = e.reasoning
if type(rc) ~= "table" then return false end
local st = rbool(rc, "support_thinking", "supportThinking")
if st == false then return false end
local opts = rc.options or rc.Options
return type(opts) == "table" and #opts > 0
end
-- parseEntry(it, channel):把一条 config_info_list 条目解成我们关心的字段。
local function parseEntry(it, chan)
local id = rstr(it, "config_name", "ConfigName")
if not id or id == "" then return nil end
local disp = it.display_config or it.DisplayConfig
if type(disp) ~= "table" then disp = {} end
local e = {
id = id,
name = rstr(disp, "display_name", "displayName") or id,
chan = chan,
usage = rstr(it, "usage", "Usage"),
enabled = rbool(it, "config_switch", "ConfigSwitch"),
hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"),
custom = rbool(disp, "is_custom_model", "IsCustomModel"),
reasoning = it.reasoning_effort_config or it.reasoningEffortConfig,
}
-- ⚠ contextWindow 取 `dev` 而非 `max`:真实条目形如 {dev:200000, max:1000000},
-- 而 max 需开启 display_config.max_mode 才可用(我们不实现该开关)。
-- 采信 max 会让上层以为有 1M 窗口、实际请求被上游拒。
local cw = it.context_window_tokens or it.contextWindowTokens
if type(cw) == "table" then
e.contextWindow = rnum(cw, "dev", "Dev")
end
-- 输出上限:model_detail_list[].max_tokens(取首条的 max_tokens)
local mdl = it.model_detail_list or it.modelDetailList
if type(mdl) == "table" and type(mdl[1]) == "table" then
e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens")
end
-- 计费:display_contact_config 是 **JSON 字符串**(需二次解析)
local dcc = it.display_contact_config or it.DisplayContactConfig
if type(dcc) == "string" and dcc ~= "" then
local okj, cfg = pcall(json.decode, dcc)
if okj and type(cfg) == "table" then
local rate = cfg.consumption_rate or cfg.ConsumptionRate
if type(rate) == "table" then
local enable = rbool(rate, "enable", "Enable")
if enable ~= false then
local data = rate.data or rate.Data
if type(data) == "table" then
local v = rnum(data, "rate", "Rate")
if v and v >= 0 then e.creditsRate = v end
end
end
end
-- 活动折扣:**不能只看 enable** —— 实测 enable=true 但当前无折扣的形态
-- 形如 {type:"none", before:0.13, after:0.13, discount:100}
-- (discount=100 表示无折扣,before==after)。照显会让用户以为有活动。
local disc = cfg.activity_discount or cfg.ActivityDiscount
if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then
local ddata = disc.data or disc.Data
if type(ddata) == "table" then
local cur = ddata.current or ddata.Current
if type(cur) == "table" then
local dtype = (rstr(cur, "discount_type", "discountType") or "")
dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "")
if dtype ~= "" and dtype ~= "none" then
local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate")
local after = rnum(cur, "consumption_rate", "consumptionRate")
-- 必须是真的降价:before 有值、为正、且严格大于 after
if before and before > 0 and (not after or before > after) then
-- 截止时间:data 下任意带 end_at 的子对象
local endsAt
for _, v in pairs(ddata) do
if type(v) == "table" then
local endv = rnum(v, "end_at", "endAt")
if endv and endv > 0 then endsAt = endv break end
end
end
local nowS = os.time and os.time() or 0
if not endsAt or endsAt > nowS then
e.origCreditsRate = before
e.discountEndsAt = endsAt
end
end
end
end
end
end
end
end
return e
end
local groups = rec.function_configs or rec.functionConfigs
if type(groups) == "table" then
local byId, chosenRank, order = {}, {}, {}
for _, g in ipairs(groups) do
if type(g) == "table" then
local chan = rstr(g, "function", "Function")
-- 准入:未声明 function 或不在白名单 → 整组丢弃
if chan and rankOf[chan] then
local list = g.config_info_list or g.ConfigInfoList
if type(list) == "table" then
for _, it in ipairs(list) do
if type(it) == "table" then
local e = parseEntry(it, chan)
if e then
-- 硬性过滤(不设开关)
local ok = true
if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end
if e.enabled == false then ok = false end
if e.hidden == true then ok = false end
-- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝)
if e.custom == true then ok = false end
if ok then
local inc = byId[e.id]
if inc == nil then
byId[e.id] = e
chosenRank[e.id] = rankOf[chan]
order[#order + 1] = e.id
else
local incEff = declaresEffort(inc)
local candEff = declaresEffort(e)
local take = true
if incEff and not candEff then
take = false -- 规则①:空档位不得覆盖有档位
elseif incEff and candEff then
-- 规则②:两侧都有档位 → 取通道优先级更靠前的
take = rankOf[chan] < (chosenRank[e.id] or 0)
end
if take then
byId[e.id] = e
chosenRank[e.id] = rankOf[chan]
end
end
end
end
end
end
end
end
end
end
for _, id in ipairs(order) do
out[#out + 1] = id
-- 元数据另存(model_list 用它报出上下文窗口/档位/倍率):
-- ⚠ 只报 id 会把参考能提供的元数据全部丢掉(档位信息在解析阶段就没了,
-- 上层只能显示"未提供推理等级")。
if not plugin.state.modelMeta then plugin.state.modelMeta = {} end
plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {}
plugin.state.modelMeta[provider][id] = byId[id]
end
end
elseif kind == "zcode_configs" then
-- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象**
-- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。
@ -3208,6 +3456,10 @@ local function refreshRemoteModels(provider)
local url = ep.url
if ep.query then url = url .. "?" .. ep.query end
local headers = { ["Accept"] = "application/json" }
-- ★ ep.body:POST 型目录端点(如 trae 的 batch_get_detail_param 必须 POST +
-- 指定请求体)。缺失时按 GET。
local method = ep.method or "GET"
if ep.body then headers["Content-Type"] = "application/json" end
local useAuth = ep.url_auth ~= false and mr.auth ~= "none"
if useAuth and tok and tok ~= "" then
headers["Authorization"] = "Bearer " .. tok
@ -3221,7 +3473,38 @@ local function refreshRemoteModels(provider)
-- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求
-- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头,
-- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。
local req = { url = url, method = ep.method or "GET", headers = headers }
local req = { url = url, method = method, headers = headers }
if ep.body then req.body = ep.body end
if ep.sign == "trae_solo" then
-- TRAE 的鉴权是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的
-- 设备身份)—— 不能只发 Bearer。这里复用**平台既有的** traeCheckinHeaders
-- 实现(它已经过实测:简化头会被拒),不另写一套头部构造,
-- 否则“chat 能通、目录 401”这类差异极难查。
local cred
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and cj.access_token then
cred = cj break
end
end
end
if not cred then
lastErr = "无可用 TRAE 凭据"
elseif type(traeCheckinHeaders) ~= "function" then
lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)"
else
local uid = cred.uid or ""
local h, herr = traeCheckinHeaders(plat, cred, uid)
if herr then
lastErr = tostring(herr)
else
-- 融平台头(后者覆盖前面的 Accept,但保留 Authorization 等)
for k, v in pairs(h or {}) do req.headers[k] = v end
end
end
end
if ep.sign == "codearts" then
local cred
for _, a in ipairs(poolList(provider)) do
@ -4874,6 +5157,7 @@ end
-- 改成 local 后这个隐式依赖就断了 —— 表现为
-- attempt to call global 'traeCallback' (a nil value)。
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
local traeCheckinHeaders, traeFixNickname
function plugin.poll_login(req)
@ -6289,6 +6573,8 @@ plugin.http = {
roles = { "admin" }, description = "列平台账号池" },
{ path = "channels/account/update", method = "POST", handler = "account_update",
roles = { "admin" }, description = "改账号昵称/启用态" },
{ path = "channels/account/clear_credentialless", method = "POST", handler = "account_clear_credentialless",
roles = { "admin" }, description = "清理无凭据账号(占位;dry_run 默认开启)" },
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
{ path = "channels/account/test", method = "POST", handler = "account_test",
@ -6412,6 +6698,91 @@ function plugin.account_add(req)
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
end
-- account_clear_credentialless:批量清掉**没有任何凭据**的账号。
--
-- ## 为什么需要它(真实场景)
-- account_add 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
-- 于是"先建占位、再走登录补齐"是正常用法。但登录若没走完(用户放弃、或历史批量
-- 导入),就会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
-- (面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"变得难以判断。
-- 实测生产有 12 个这种占位(codearts 10 + trae 2),13 个账号里只有 1 个真可用。
--
-- ## 安全性(**破坏性**操作,必须能证明它只删该删的)
-- ① 只删没有凭据的:有凭据的账号**一律不动**(哪怕它测试失败 —— 那是"可疑"
-- 而不是"空壳",两者处置完全不同);
-- ② dry_run **默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
-- ③ 复用 plugin.account_delete(同一份删除实现)—— 删除时要顺带做的事
-- (drop source、清旧单账号源、删凭据)不能另写一套,否则必然漂移。
-- ⚠️ 不写成顶层 `local`:本文件顶层 local 数已临近 Lua 的单函数 200 上限
-- (加它会直接 luac -p 报 "too many local variables")。
-- 挂在 plugin 表上同样只有一份实现,且不占顶层名额。
plugin.__hasCredential = function(acct)
if not acct or not acct.credentialRef then return false end
local v = __llmsproxy_secrets.get(acct.credentialRef)
return type(v) == "string" and v ~= ""
end
function plugin.account_clear_credentialless(req)
local args = bodyOf(req)
-- ⚠️ 不要照其它 handler 写 `if args.platform == nil then args = req end`:
-- 那个回退是为「直接把参数当 req 传」的旧调用准备的,但它会**丢掉 body**
-- —— 本函数不传 platform 是**合法用法**(清全部平台),此时 args.platform
-- 自然为 nil,回退会把 args 换成 HandlerRequest ⇒ 刚解出来的 dry_run 就没了,
-- 于是「传 dry_run=false 却被当成未传」而静默只预演(破坏性操作里最糟的形态:
-- 操作者以为执行了、实际没执行,或者反过来)。
-- 实测踩到:不带 platform 时 deleted 恒为 nil。
-- 兜底只认「req 本身就是参数表」这种形态。
if type(req) == "table" and req.body == nil and req.query == nil then args = req end
local dry = args.dry_run
if dry == nil then dry = true end
local platforms
if args.platform and args.platform ~= "" then
local plat = platformById(args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
platforms = { plat }
else
platforms = allPlatforms()
end
local victims, kept = {}, 0
for _, plat in ipairs(platforms) do
for _, a in ipairs(poolList(plat.id)) do
if plugin.__hasCredential(a) then
kept = kept + 1
else
victims[#victims + 1] = { platform = plat.id, id = a.id, nickname = a.nickname }
end
end
end
if dry then
return { status = 200, json = {
ok = true, dry_run = true, would_delete = #victims,
kept_with_credential = kept, accounts = victims,
message = "预演:将删除 " .. #victims .. " 个无凭据账号(有凭据的 " .. kept
.. " 个不动)。确认后传 dry_run=false 执行。",
} }
end
local done, failed = {}, {}
for _, v in ipairs(victims) do
local r = plugin.account_delete({ platform = v.platform, account_id = v.id })
if r and r.json and r.json.ok then
done[#done + 1] = v.platform .. "/" .. v.id
else
failed[#failed + 1] = v.platform .. "/" .. v.id
end
end
logEvent("清理无凭据账号 " .. #done .. " 个(失败 " .. #failed .. ")")
return { status = 200, json = {
ok = true, dry_run = false, deleted = #done, failed = #failed,
kept_with_credential = kept, deleted_accounts = done,
failed_accounts = #failed > 0 and failed or nil,
} }
end
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
function plugin.account_list(req)
local args = bodyOf(req)
@ -6741,6 +7112,40 @@ function plugin.model_list(req)
toolCall = cap.toolCall,
}
end
-- ★ 远端目录解析出的元数据(如 trae 的上下文窗口/输出上限/思考档位/倍率)。
-- 之前目录只报 id ⇒ 参考能提供的这些字段全丢,UI 只能显示"未提供推理等级"。
-- 能力表(models.dev)没给的值用远端目录补(远端是**权威**:它就是这个
-- 通道实际可用的配置)。两者都有时不覆盖能力表(后者是跨平台归一后的口径)。
local rm = plugin.state.modelMeta and plugin.state.modelMeta[plat.id]
and plugin.state.modelMeta[plat.id][m]
if rm then
entry.remote = {}
if rm.contextWindow then
entry.remote.contextWindow = rm.contextWindow
if not (cap and cap.contextWindow) then entry.contextWindow = rm.contextWindow end
end
if rm.maxOutputTokens then
entry.remote.maxOutputTokens = rm.maxOutputTokens
if not (cap and cap.maxOutputTokens) then entry.maxOutputTokens = rm.maxOutputTokens end
end
-- ⚠ `function` 是 Lua 关键字,必须用中括号键(本文件第 3 次踩)。
if rm.chan then entry.remote["function"] = rm.chan end
if rm.creditsRate then entry.remote.creditsRate = rm.creditsRate end
if rm.origCreditsRate then entry.remote.origCreditsRate = rm.origCreditsRate end
if rm.discountEndsAt then entry.remote.discountEndsAt = rm.discountEndsAt end
-- 思考档位:从远端条目解出(列表空/显式 support_thinking:false 则视为无)
local rc = rm.reasoning
if type(rc) == "table" then
local st = rc.support_thinking
if st == nil then st = rc.supportThinking end
local opts = rc.options or rc.Options
if st ~= false and type(opts) == "table" and #opts > 0 then
entry.remote.efforts = opts
if not (cap and cap.efforts) then entry.efforts = opts end
entry.reasoning = true
end
end
end
out[#out+1] = entry
end
end
@ -8573,11 +8978,22 @@ function claimZcode(plat, tok, args)
else
local ok, rec = pcall(json.decode, resp.body or "")
if ok and type(rec) == "table" then
-- ⚠️ 业务码可能是**纯数字字符串**("3012"),tonumber 必须能认
local code = tonumber(rec.code) or tonumber(tostring(rec.code or ""))
-- ⚠️ 业务码可能是**纯数字字符串**("3012"):ZD 归一。
local code = (ZD and ZD.parseBusinessCode(rec.code))
or tonumber(rec.code) or tonumber(tostring(rec.code or ""))
local msg = tostring(rec.msg or rec.message or "")
if code == 3012 or msg:find("unusual activity", 1, true) then
failedMsg = "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
-- ★ 风控判定走**共用模块**(与推理路径同一份源码文本)
-- 见 ZCODE_DIAG_SRC 的头注释:3012 有账号冷却惩罚,
-- "请勿连续重试"必须同时出现在两条路径上。
-- 判定本身:只信业务码(3012 才是风控),无码可读时才看正文语义 ——
-- 裸数字会把 `retry after 3012 ms`(限流)误判成风控,而两者动作相反。
local isRisk = (ZD and ZD.hasUnusualActivity(resp.body or ""))
or (code == 3012) or (msg:find("unusual activity", 1, true) ~= nil)
if isRisk then
failedMsg = (ZD and ZD.formatUnusualActivityMessage({
subject = "领取 " .. tostring(planId or ""),
rawResponse = msg,
})) or "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
elseif code and code ~= 0 and code ~= 200 then
failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code))
else
@ -9002,6 +9418,326 @@ end
return adapter
]==]
local ZCODE_DIAG_SRC = [===[
-- ============================================================================
-- ZCode 3012 判据 / 文案 / 诊断(**唯一出处**,两处消费者共用同一份源码)
--
-- 消费者:
-- ① 插件主体(领取路径 claimZcode);
-- ② 自装适配器 channels-zcode(推理路径的错误分类 + 请求体形态诊断)。
-- 两者由 installZcodeAdapter 把本段**前置**到适配器源码里实现共用
-- (照参考的设计:formatZcodeUnusualActivityMessage 被两条路径引用同一份,
-- 因为 3012 不是"重试就能过",而是**有账号冷却惩罚**——"请勿连续重试"
-- 是这段文案里最重要的一句,绝不能只在其中一条路径上有)。
--
-- ## 为什么不能只看裸数字(参考 2026-10-05 对抗性审计的实测结论)
--
-- `too many requests, retry after 3012 ms` → 仅看数字会**误判成风控**
-- `<h1>Error 3012</h1>upstream connect timeout` → 同上
--
-- 前者后果**不是文案问题**:限流是"稍后重试即可"、风控是"**别重试**",
-- 两者用户动作**完全相反**;且领取路径据此**短路**,跳过本可领取的 plan。
--
-- ## 为什么也不能用"独立词 3012"当门禁
--
-- 上游 3012 的 `msg` 原文就是 `request has been blocked due to unusual
-- activity.` —— **不含任何数字**(数字在 `code` 字段里)。以真实文案做样本时,
-- "必须有独立词 3012"这条门禁会**反过来把它漏掉**。
-- ⇒ 判据以**语义短语**为主,数字只作兜底(且必须与 block 字样紧邻)。
-- ============================================================================
local Z = {}
-- isUnusualActivityCode(code):码是否为 3012。只认 number 与**纯数字字符串**。
--
-- ⚠ 必须认字符串形态(真实缺陷 2026-10-05):上游会回
-- {"code":"3012","msg":"..."},而只认 number 的解析会让风控码整条丢失,
-- 用户看到的正是那句裸上游串(漏判的直接原因)。
-- ⚠ 只认**纯数字**(^%-?%d+$),不把任意文本静默变成业务码。
function Z.isUnusualActivityCode(code)
if type(code) == "number" then return code == 3012 end
if type(code) ~= "string" then return false end
return code:match("^%s*3012%s*$") ~= nil
end
-- parseBusinessCode(value):把上游 code 归一成数字;认 number 与纯数字字符串。
function Z.parseBusinessCode(value)
if type(value) == "number" then
if value ~= value or value == math.huge or value == -math.huge then return nil end
return value
end
if type(value) ~= "string" then return nil end
local t = value:match("^%s*(.-)%s*$")
if not t:match("^%-?%d+$") then return nil end
return tonumber(t)
end
-- looksLikeUnusualActivity(body):正文里以**独立词**出现 3012。
--
-- ⚠ 必须是词边界,不能是 body:find("3012", 1, true)(真实缺陷):裸子串会把
-- 其它码里的数字片段一并命中,实测误判形态:
-- {"code":13012} → 误命中
-- plan-3012-trust → 误命中
-- 后果不是"文案不好看":领取路径据此短路,把"plan 名里恰好含 3012"的普通
-- 失败当成风控,**跳过本可领取的 plan**,并把真实业务码(如 1005 额度用尽)
-- 改写成 3012 —— 两者用户动作完全相反(等额度重置 vs 别重试)。
--
-- ⚠ 边界集必须含 **`-` 与 `_`**:初版只排除数字 ⇒ `plan-3012-trust` 仍命中;
-- 第二版补字母 ⇒ 仍命中(连字符不属 [0-9A-Za-z])。故取 [0-9A-Za-z_-]。
-- `.` `,` `}` `:` 空格 行首行尾都**不**被排除,故上游真实形态
-- {"code":3012,...} 与自然语言 `error: 3012 blocked` 照常命中。
function Z.looksLikeUnusualActivity(body)
if type(body) ~= "string" then return false end
-- Lua 无 lookbehind/lookahead ⇒ 手工检查两个前导/后继字符
local from = 1
while true do
local s, e = body:find("3012", from, true)
if not s then return false end
local before = s > 1 and body:sub(s - 1, s - 1) or ""
local after = e < #body and body:sub(e + 1, e + 1) or ""
local function bad(c)
if c == "" then return false end
return c:match("[0-9A-Za-z_%-]") ~= nil
end
if not bad(before) and not bad(after) then return true end
from = s + 1
end
end
-- mentionsUnusualActivity(body):正文是否**表达**了风控(仅凭正文时的唯一入口)。
--
-- ① 主判据:上游**实测原文**的语义短语(覆盖 `request has been blocked due to
-- unusual activity.` 及其变体);
-- ② 兜底:独立词 3012 与 block 字样**紧邻(≤10 字符)**。
--
-- ⚠ 为什么必须"紧邻"而不是"全文共现"(真实缺陷 N2):第一版写成"独立词 3012
-- 且全文含 block",仍会误判限流正文:
-- `rate limited: request blocked, retry after 3012 ms` → 距 14,误判
-- `temporarily blocked; see error 3012` → 距 12,误判
-- 而 `3012 blocked by waf` → 距 1,应命中
-- 窗口 10 是实测收出来的(40 仍放过上面两个反例)。
function Z.mentionsUnusualActivity(body)
if type(body) ~= "string" then return false end
if body:lower():find("unusual activity", 1, true) then return true end
if body:lower():find("blocked due to", 1, true) then return true end
if not Z.looksLikeUnusualActivity(body) then return false end
-- 紧邻检查:3012 前后 10 字符内出现 block
local low = body:lower()
local from = 1
while true do
local s, e = low:find("3012", from, true)
if not s then return false end
local before = s > 1 and low:sub(math.max(1, s - 10), s - 1) or ""
local after = e < #low and low:sub(e + 1, math.min(#low, e + 10)) or ""
if before:find("block", 1, true) or after:find("block", 1, true) then return true end
from = s + 1
end
end
-- readBusinessCodeFromBody(body):从响应正文读业务码(认 number 与数字字符串)。
function Z.readBusinessCodeFromBody(body)
if type(body) ~= "string" then return nil end
local ok, rec = pcall(json.decode, body)
if not ok or type(rec) ~= "table" then return nil end
return Z.parseBusinessCode(rec.code)
end
-- hasUnusualActivity(body):**有优先级**——正文能解析出业务码就只信码;
-- 无码可读才看正文语义。
--
-- ⚠ 优先级是必须的:{"code":3012} 这种正文**不含语义词**,直接走文本判定会
-- 漏判 ⇒ 错误被归为可重试类 ⇒ 有**冷却惩罚**的风控被白重试多次
-- (qoder 那边记过同型缺陷)。
function Z.hasUnusualActivity(body)
local code = Z.readBusinessCodeFromBody(body)
if code ~= nil then return Z.isUnusualActivityCode(code) end
return Z.mentionsUnusualActivity(body)
end
-- formatUnusualActivityMessage(opts):3012 的**共用文案**。
-- opts.subject(定位信息,如 planId)/ opts.diagnostic / opts.rawResponse。
function Z.formatUnusualActivityMessage(opts)
opts = opts or {}
local subject = (type(opts.subject) == "string" and opts.subject ~= "")
and (" · " .. opts.subject) or ""
local diag = (type(opts.diagnostic) == "string" and opts.diagnostic ~= "")
and ("本机诊断:" .. opts.diagnostic .. "\n") or ""
local raw = (type(opts.rawResponse) == "string" and opts.rawResponse ~= "")
and ("原始响应:" .. opts.rawResponse) or ""
return "上游风控拦截(3012 unusual activity)" .. subject .. "。"
.. "⚠ 该错误有账号冷却惩罚(30 分钟,反复触发会升级到 24 小时乃至停用),"
.. "请勿连续重试。\n" .. diag .. raw
end
-- ============================================================================
-- 进程级账号画像(只给诊断用;**绝不写凭据**)
-- ============================================================================
-- 红线(照参考):账号只以**进程内自增序号**出现,不含 accountId/JWT/账号名
-- 的任何字符片段。
local profiles, nextOrdinal = {}, 1
local function normalizeKey(accountId)
if accountId == nil or accountId == "" then return "-" end
return tostring(accountId)
end
-- accountOrdinal(accountId):进程内序号,**首次见到时分配**(之后稳定)。
-- 同一账号跨请求必须是同一个序号,否则"本次是第几个账号"会随请求变化。
function Z.accountOrdinal(accountId)
local k = normalizeKey(accountId)
local p = profiles[k]
if p then return p.ordinal end
p = { ordinal = nextOrdinal, sent = 0, ok = 0, failed = 0 }
nextOrdinal = nextOrdinal + 1
profiles[k] = p
return p.ordinal
end
-- noteRequestSent(accountId, sentAt):记一次"请求已发出"。
-- ⚠ sentAt 必须在**真正发出之前**取,否则"距上一条"会把本次排队时间算进去。
function Z.noteRequestSent(accountId, sentAt)
Z.accountOrdinal(accountId)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.sent = p.sent + 1
-- ⚠ 旧值**下沉一格**再写新的:诊断要的"距上条"是**上一条**,不是本次。
-- 若拿 lastSentAt 顶替,算出来恒为 0ms,这一项就永远没有信息量
-- (参考做端到端验证时真的打印出过"距上条 0ms")。
p.previousSentAt = p.lastSentAt
p.lastSentAt = sentAt
end
function Z.noteRequestOk(accountId, at)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.ok = p.ok + 1
p.lastOkAt = at
end
function Z.noteRequestFailed(accountId)
local p = profiles[normalizeKey(accountId)]
if not p then return end
p.failed = p.failed + 1
end
-- formatGap(ms):把毫秒差写成一眼能读的形态。
--
-- ⚠ 各档**逐档单独算**:参考初版把分钟档写成 floor(seconds % 60),于是 90 秒
-- 被渲染成"30分30秒"(分位取的是**秒的余数**)。该错误在真实探针输出里发现,
-- 纯函数单测当时没覆盖 ≥60 秒的区间 —— 故本实现直接按 totalSeconds 拆。
function Z.formatGap(ms)
if ms == nil or ms < 0 then return "—" end
if ms < 1000 then return tostring(math.floor(ms + 0.5)) .. "ms" end
local totalSeconds = math.floor(ms / 1000)
if totalSeconds < 60 then return string.format("%.1fs", ms / 1000) end
local minutes = math.floor(totalSeconds / 60)
if minutes < 60 then
local rest = totalSeconds % 60
if rest == 0 then return tostring(minutes) .. "分" end
return tostring(minutes) .. "分" .. tostring(rest) .. "秒"
end
local hours = math.floor(minutes / 60)
local restMin = minutes % 60
if restMin == 0 then return tostring(hours) .. "小时" end
return tostring(hours) .. "小时" .. tostring(restMin) .. "分"
end
local function formatSinceLastOk(at, now)
if at == nil then return "从未成功过" end
return Z.formatGap(now - at) .. "前"
end
-- describeRequestShape(system, messages):读**真正要发出去的那个对象**的形态。
--
-- ⚠ 刻意**不读**常量(如"官方身份块 2898 字符"):常量只能证明"源码里写了多少
-- 字",证明不了"线上发出去多少字"。两者一旦不一致(例如某条改动把 system
-- 顶掉了),只有读实际值才看得出来 —— 它会**如实显示 0 字符**。
function Z.describeRequestShape(system, messages)
local blocks = type(system) == "table" and system or {}
local function blockChars(b)
if type(b) ~= "table" then return 0 end
return type(b.text) == "string" and #b.text or 0
end
local n = #blocks
local identityChars = 0
local perBlock = {}
for i = 1, math.min(2, n) do
local c = blockChars(blocks[i])
perBlock[#perBlock + 1] = c
identityChars = identityChars + c
end
local first = type(messages) == "table" and messages[1] or nil
local hasDate = false
if type(first) == "table" then
local c = first.content
if type(c) == "table" and type(c[1]) == "table" and type(c[1].text) == "string" then
hasDate = c[1].text:sub(1, 17) == "<system-reminder>"
elseif type(c) == "string" then
hasDate = c:sub(1, 17) == "<system-reminder>"
end
end
return {
systemBlocks = n,
identityChars = identityChars,
identityBlockChars = perBlock,
cliPrefixChars = n >= 1 and blockChars(blocks[1]) or 0,
hasDateBlock = hasDate,
firstRole = (type(first) == "table" and tostring(first.role)) or "",
}
end
-- formatDiagnostic(input):拼一行现场数据(**不含任何凭据片段**)。
function Z.formatDiagnostic(input)
input = input or {}
local p = profiles[normalizeKey(input.accountId)]
local ordinal = (p and p.ordinal) or Z.accountOrdinal(input.accountId)
local gap = (p and p.previousSentAt ~= nil)
and Z.formatGap((input.sentAt or 0) - p.previousSentAt) or "—"
local parts = {
"账号#" .. tostring(ordinal),
"本进程 成功 " .. tostring(p and p.ok or 0) .. "/失败 " .. tostring(p and p.failed or 0),
"最近成功 " .. formatSinceLastOk(p and p.lastOkAt, input.now or 0),
"距上条 " .. gap,
}
local shape = input.shape
if shape then
-- ⚠ 用 ~= nil 而非真值判断:Lua 里表恒为真,真正的分支是"传了/没传"
local per = #(shape.identityBlockChars or {}) > 0
and table.concat(shape.identityBlockChars, "+") or "无"
parts[#parts + 1] = "身份块 " .. tostring(shape.identityChars or 0)
.. " 字符(" .. per .. ")"
parts[#parts + 1] = "日期块 " .. (shape.hasDateBlock and "有" or "无")
end
parts[#parts + 1] = "HTTP " .. tostring(input.status)
return table.concat(parts, " · ")
end
-- resetDiagnostics():清空全部进程级状态(只给判据用)。
function Z.resetDiagnostics()
profiles = {}
nextOrdinal = 1
end
return Z
]===]
-- ★ ZCode 3012 判据/文案/诊断的**唯一出处**(插件主体与 channels-zcode 适配器
-- 共用同一份源码文本)。
--
-- 为什么必须共用:3012 不是"重试就能过",而是**有账号冷却惩罚**(30 分钟起步,
-- 反复触发升级到 24 小时甚至停用)。"请勿连续重试"是这段文案里最重要的一句,
-- 绝不能只在领取路径或只在推理路径上有(参考为此专门把文案抽成单一份)。
--
-- 本段在插件主体里**本地执行**得到 ZD;适配器里同文本前置进它自己的环境。
local ZD = (function()
local env = setmetatable({ json = json }, { __index = _G })
local f = load(ZCODE_DIAG_SRC, "zcode-diag")
if not f then return nil end
local ok, mod = pcall(f)
if not ok or type(mod) ~= "table" then return nil end
return mod
end)()
local ZCODE_ADAPTER_SRC = [===[
-- ============================================================================
-- ZCode 专用适配器(插件自装:channels-zcode)
@ -9250,18 +9986,40 @@ function adapter.transform_request(raw_body)
req.system = buildSystemBlocks(caller, opts)
-- 日期块:插到**第一条** user 消息的 content 数组最前(官方形态)。
-- ★ 日期块:只处理**第一条**消息,且它必须是 role == "user"(官方行为)。
--
-- 两条容易漏掉的规则(照参考 withContextPrefix,「优化」它们会偏离官方):
-- ① **幂等**:已以 <system-reminder> 开头则不重复插。不判的话,同一请求
-- 在重试/多次 transform 时会把日期块叠成好几层,改变前缀结构;
-- ② **只看第一条**:若首条不是 user(例如 assistant 开场),**不动** ——
-- 不是"往后找一个 user 消息"。我第一版就是往后找,与官方行为不符。
local msgs = req.messages
if type(msgs) == "table" then
for _, m in ipairs(msgs) do
if type(m) == "table" and m.role == "user" then
local c = m.content
local first = msgs[1]
if type(first) == "table" and first.role == "user" then
local c = first.content
-- 已插过(字符串或块数组两种形态都要判)→ 不重复插。
-- ⚠ 长度必须用 #SR 算,不能写死 —— 我第一版写 `sub(1, 16)` 而
-- "<system-reminder>" 是 **17** 字符,判断**永远为假** ⇒ 幂等失效、
-- 重试时日期块叠加(判据当场抓到)。
local SR = "<system-reminder>"
local already = false
if type(c) == "string" then
already = c:sub(1, #SR) == SR
elseif type(c) == "table" and type(c[1]) == "table" then
local t = c[1].text
already = type(t) == "string" and t:sub(1, #SR) == SR
end
if not already then
local blk = contextPrefixBlock()
if type(c) == "table" then
table.insert(c, 1, contextPrefixBlock())
table.insert(c, 1, blk)
elseif type(c) == "string" then
m.content = { contextPrefixBlock(), { type = "text", text = c } }
-- 纯文本 content 转成块数组(官方就是数组形态)
first.content = { blk, { type = "text", text = c } }
else
first.content = { blk }
end
break
end
end
end
@ -9269,6 +10027,33 @@ function adapter.transform_request(raw_body)
return json.encode(req)
end
-- ★ transform_error:把 3012 判成**不可重试**并带上现场诊断。
--
-- 为什么不能只依赖通用文案:3012 有账号冷却惩罚(30 分钟起步),
-- 归成"可重试的服务端错误"会让调用方**白重试**、把冷却越推越长。
-- 诊断行(账号序号/成功失败计数/距上条/身份块实测字符数)是排查的全部现场,
-- 且**不含任何凭据片段**(只出现进程内自增序号)。
function adapter.transform_error(status, body)
local text = tostring(body or "")
if Z and Z.hasUnusualActivity(text) then
return Z.formatUnusualActivityMessage({
diagnostic = Z.formatDiagnostic({
accountId = nil,
sentAt = os.time and os.time() * 1000 or 0,
now = os.time and os.time() * 1000 or 0,
status = status,
}),
rawResponse = text:sub(1, 500),
})
end
-- 非风控:交回 anthropic 基座的通用分类
if type(BASE) == "table" and type(BASE.transform_error) == "function" then
local ok, res = pcall(BASE.transform_error, status, body)
if ok and type(res) == "string" then return res end
end
return text
end
-- 暴露给判据:身份块长度与装载状态("装了但身份块为空"这类问题没有它只能靠猜)
adapter.__identity_probe = function()
return {
@ -9451,11 +10236,19 @@ end
-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。
local function installZcodeAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(ZCODE_ADAPTER_SRC)
-- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。
-- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)——
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
-- ⚠ 不能直接拼接:ZCODE_DIAG_SRC 末尾是 `return Z`,直接拼会让那个 return
-- 结束**整个 chunk** ⇒ 后面的适配器代码永不执行("加载成功但适配器不存在",
-- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
"\nend)()\n" .. ZCODE_ADAPTER_SRC
local f, lerr = load(full)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-zcode", ZCODE_ADAPTER_SRC)
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-zcode", ZCODE_ADAPTER_SRC)
local _, ierr = __llmsproxy_adapters.install("channels-zcode", full)
if ierr then return false, ierr end
end
return true, nil
@ -10353,6 +11146,57 @@ function plugin.claims_capable(args)
} }
end
-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。
-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题
-- 没有它只能靠猜。
function plugin.__trae_entry_probe(args)
args = args or {}
local id = tostring(args.model or "")
local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae
local e = meta and meta[id]
if not e then return { json = { error = "无该模型元数据" } } end
local rc = e.reasoning
local opts = type(rc) == "table" and (rc.options or rc.Options) or nil
return { json = {
id = id, chan = e.chan, usage = e.usage,
enabled = e.enabled, hidden = e.hidden, custom = e.custom,
has_reasoning = rc ~= nil,
n_options = type(opts) == "table" and #opts or 0,
support_thinking = type(rc) == "table"
and (rc.support_thinking or rc.supportThinking) or nil,
} }
end
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
--
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在
-- channels-zcode 适配器)。判据必须能直接打**那一份**实现,否则会退化成
-- “各自测各自的副本”,而共用一旦漂移就再也测不出来。
function plugin.__zcode_diag_probe(args)
args = args or {}
local fn = tostring(args.fn or "")
if not ZD then return { json = { error = "诊断模块未装载" } } end
local f = ZD[fn]
if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end
if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end
if fn == "formatUnusualActivityMessage" then
return { json = { result = f(args.opts or {}) } }
end
if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end
if fn == "describeRequestShape" then
return { json = { result = f(args.system, args.messages) } }
end
if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end
if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end
if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity"
or fn == "looksLikeUnusualActivity" then
return { json = { result = f(args.a) } }
end
return { json = { error = "探针未暴露该函数: " .. fn } }
end
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
function plugin.__adapter_install_probe()