Compare commits

...

5 Commits

Author SHA1 Message Date
2b8fcaf434 fix(opencodezen): 必须回传 reasoning_content(thinking 模式硬要求)
## 真实缺陷(我自己的会话被它打断过)
生产实测(2026-10-16):
  provider localzen: api error 400: Upstream request failed: [invalid_request_error]
  The `reasoning_content` in the thinking mode must be passed back to the API.
审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 部署后需补的一次操作
生产权限集当前只有 add_source ⇒ 需授予 remove_source(否则既有账号的
"停用/删除"仍撤不掉源)。已在部署后手工授予,并复核 declared == granted。
2026-10-16 10:04:13 +08:00
7 changed files with 711 additions and 39 deletions

View File

@ -0,0 +1,57 @@
package core
import (
"strings"
"testing"
)
// ★ 远端模型目录混着「不可调用」的条目,必须过滤掉。
//
// 真实缺陷(生产实测):buddy 源声明了 24 个模型,其中
//
// nes-gf / codewise-completions / codewise-rewrite / codewise-jump /
// codewise-default-model-v2 / hunyuan-image-alpha / auto
//
// 全部调不通(`glm-5.1` 也是 404)。用户选中就报错,而错误文案指向**模型**,
// 极易被误判成"这个模型坏了"。
//
// 参考实现(buddy.ts 的 isChatModel / isAutoSelectAlias)记录了**四类**判据,
// 且都是实测依据:
//
// ① 自动选择别名 auto/default(**只按字面量**,前缀匹配会误伤 default-model)
// ② 补全/NES 专用前缀 nes- / completion- / codewise-,或 supportsExtra=true
// (参考实测 codewise-default-model-v2 → `code 11102 service info not found`)
// ③ maxOutputTokens >0 且 ≤256(补全用途;对话模型普遍 ≥24000)
// ④ 带 text-to-image 标签(如 hunyuan-image-alpha)
func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
// 判据一:四类规则必须都在源码里(防止有人"精简"掉其中一条)
for _, rule := range []string{
`id == "auto" or id == "default"`, // ①
`id:sub(1, 4) == "nes-"`, // ②
`id:sub(1, 11) == "completion-"`, // ②
`id:sub(1, 9) == "codewise-"`, // ②
`m.supportsExtra == true`, // ②
`mo > 0 and mo <= 256`, // ③
`t == "text-to-image"`, // ④
} {
if !strings.Contains(s, rule) {
t.Errorf("buddy 目录过滤缺少规则:%s\n"+
"(这四类都是参考用真实请求验证过的「列出来就会让用户报错」的条目,"+
"删任何一条都会把不可调用模型放回选择器)", rule)
}
}
// 判据二:`default-model` / `fast-model` 必须**不在**被过滤的形态里。
// 参考注释明确警告:只过滤 auto/default 两个字面量,不做前缀匹配 ——
// 前缀会误伤这两个国际版别名(它们被 craft agent 引用,是官方推荐入口)。
if strings.Contains(s, `id:sub(1, 7) == "default"`) {
t.Error("buddy 过滤把 default 做成了**前缀**匹配 ⇒ 会误伤 default-model(参考点名警告)")
}
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,8 +11,14 @@ adapter.endpoint = "/chat/completions"
--
-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端
-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in
-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传
-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。
-- OpenCode"。
-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于
-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** ——
-- 剥掉恰恰会引发
-- The `reasoning_content` in the thinking mode must be passed back to the API
-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」
-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。
-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。
-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content,
-- 否则 thinking 模式直接 400。
--
@ -219,26 +225,50 @@ function adapter.transform_request(raw_body)
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
msg.role = "system"
end
-- Zen 免费池不接受回传 reasoning_content(Go 侧相反)
msg.reasoning_content = nil
-- ★ 必须**保留** reasoning_content(不再剥离)。
--
-- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池
-- 不接受回传」。**生产实测证伪**(2026-10-16):
-- provider localzen: api error 400: Upstream request failed:
-- [invalid_request_error] The `reasoning_content` in the thinking
-- mode must be passed back to the API.
-- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** ——
-- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、
-- reasoning_effort 开启、长上下文)时,回放该历史才会被拒;
-- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。
-- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的
-- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。
--
-- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器
-- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
-- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
-- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。
--
-- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠
-- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」
-- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),
-- 需要时缺了就是硬 400。
if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then
msg.reasoning_content = nil
end
local drop = false
if type(msg.content) == "table" then
local parts = {}
for _, part in ipairs(msg.content) do
if type(part) == "table" and part.type ~= nil and part.type ~= "text" then
-- multimodal part not supported by zen
-- multimodal part not supported by zen
else
table.insert(parts, part)
end
end
if #parts == 0 then
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
-- Content collapsed to nothing after stripping unsupported
-- parts. A message that still carries a tool call must
-- NEVER be dropped: the very next message is its tool
-- result, and dropping the call orphans that result. The
-- model then sees a result for a call it never made and
-- re-issues the same tool call on every turn (observed as
-- an infinite "repeated tool call" loop).
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
or msg.tool_call_id ~= nil then
msg.content = ""

View File

@ -48,11 +48,23 @@ local plugin = {
}
-- 申请的内核权限。设置页会把"申请中但未授权"标红。
plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "status" }
-- 声明本插件要用的内核动作(运维据此授予;declared 与 granted 成对展示)。
--
-- ★ 这份清单必须与实际调用**逐一对齐**:`remove_source` 曾被漏声明,
-- 而插件在 5 处调用它(syncAccountSource 的重建、dropAccountSource、
-- account_delete 收尾、两处清旧单账号源)⇒ 生产只授了 add_source,
-- 于是**撤源全部被拒**。更坏的是它**静默**:AddSource 是**同名 upsert**,
-- 所以"先 remove 再 add"的 remove 失败看不出来,只有**纯删除路径**
-- (删账号/停用)才暴露 —— 表现是「账号删了,它的源还在对外服务」。
-- ⇒ 加动作时**同时**改这里;判据见 channels_kernel_declaration_test.go。
plugin.kernel = {
"add_source", "remove_source", "list_sources",
"probe_all", "reset_health", "status",
}
-- 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", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__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" }
@ -332,7 +344,13 @@ local PLATFORMS = {
},
hosts = { "api.workos.com", "api.cline.bot", "app.cline.bot" },
-- cline 的 chat 路径是 /api/v1/chat/completions(参考 CLINE_CHAT_PATH)。
base_url = "https://api.cline.bot/api/v1/chat/completions",
base_url = "https://api.cline.bot",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../api/v1/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/api/v1/chat/completions",
-- 远端模型目录(照参考 cline-models.ts:**必须同时打两个端点**)。
-- 关键:/api/v1/models 的 460 个 id 里**没有** cline-free/*,免费模型只由
-- recommended-models 下发;只打前者会看不到任何免费模型。
@ -402,7 +420,13 @@ local PLATFORMS = {
},
hosts = { "copilot.tencent.com", "www.codebuddy.cn" },
-- buddy 的 chat 端点含 /v2 前缀(参考 CHAT_API_BASE = .../v2 + /chat/completions)。
base_url = "https://copilot.tencent.com/v2/chat/completions",
base_url = "https://copilot.tencent.com",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../v2/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/v2/chat/completions",
-- 远端模型目录(照参考 buddy.ts CONFIG_PATH + buddy-oauth.ts fetchModels)。
-- 关键:必须带 product 身份头,否则 WorkBuddy 会以 CodeBuddy 身份请求。
models_remote = {
@ -676,7 +700,13 @@ local PLATFORMS = {
fallback = "2026.9.4",
},
-- lobsterai 的 chat 路径是 /api/proxy/v1/chat/completions(参考 LOBSTERAI_CHAT_PATH)。
base_url = "https://lobsterai-server.youdao.com/api/proxy/v1/chat/completions",
base_url = "https://lobsterai-server.youdao.com",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../api/proxy/v1/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/api/proxy/v1/chat/completions",
-- 远端模型目录(照参考 lobsterai.ts LOBSTERAI_MODELS_PATH)
models_remote = {
-- 参考真值 LOBSTERAI_API_BASE(lobsterai-server.youdao.com)。
@ -715,7 +745,17 @@ local PLATFORMS = {
vendor = "opencode",
flow = "paste",
-- opencode 的 chat 路径是 /v1/chat/completions(参考 chatPath)。
base_url = "https://opencode.ai/zen/v1/chat/completions",
-- ★ base_url 必须保留 `/zen` 段:参考 opencode-product.ts:56 的
-- `baseUrl: 'https://opencode.ai/zen'` + chatPath `/v1/chat/completions`。
-- 实测(无凭据):
-- https://opencode.ai/zen/v1/chat/completions → 500(路由存在,鉴权前先炸)
-- https://opencode.ai/v1/chat/completions → 404 + HTML(网页)
base_url = "https://opencode.ai/zen",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
-- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。
endpoint = "/v1/chat/completions",
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
models_remote = {
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
@ -841,7 +881,13 @@ local PLATFORMS = {
interval = 2,
},
-- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。
base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions",
base_url = "https://api2-v2.qoder.sh",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../model/v1/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/model/v1/chat/completions",
-- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽)
-- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。
-- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。
@ -995,9 +1041,24 @@ local PLATFORMS = {
access_key_secret = "zsak6eadrbawz683wf5r3m2snrwj868r",
app_id = "GM3LOOMY",
},
-- loomy 的 chat 路径是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494
-- 实测标准 OpenAI 兼容)。
base_url = "https://loomy.xunfei.cn/chat/completions",
-- loomy 的 chat 端是 {apiBase}/chat/completions(参考 loomy-adapter.ts:494)。
--
-- ★ host 是 **loomyad.xunfei.cn/api/v1**(参考 LOOMY_API_BASE),不是
-- `loomy.xunfei.cn` —— 后者是 **Web 前端/OAuth 回调**域(本块的
-- wechat.redirect_uri 正用它)。实测:
-- POST https://loomy.xunfei.cn/chat/completions
-- → 200 + `<!DOCTYPE html>`(网页!根本不是 API)
-- POST https://loomyad.xunfei.cn/api/v1/chat/completions
-- → 200 + `{"code":"100002","desc":"缺少 token"}`(正常 JSON)
-- 写成前端域时,无论路径怎么拼都不可能是 API。
--
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:内核的 URL 是
-- `base_url + endpoint`(provider.go ChatURL),而适配器默认 endpoint 是
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
-- .../chat/completions/chat/completions ⇒ 上游回
-- `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
base_url = "https://loomyad.xunfei.cn/api/v1",
endpoint = "/chat/completions",
adapter = "openai",
-- 兜底目录(照参考 LOOMY_FALLBACK_MODELS)。此前 "loomy-default" 是我编的。
models = {
@ -1032,7 +1093,13 @@ local PLATFORMS = {
},
-- 推理是标准 OpenAI 兼容(实测 POST /api/web/llm/v2/chat/completions)
-- raccoon 的 chat 端点是 /api/web/llm/v2/chat/completions。
base_url = "https://xiaohuanxiong.com/api/web/llm/v2/chat/completions",
base_url = "https://xiaohuanxiong.com",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL),
-- 而适配器默认 endpoint 是 "/chat/completions" ⇒ 若 base_url 已含
-- 完整路径,实际请求就变成 .../api/web/llm/v2/chat/completions/chat/completions ⇒ 上游
-- 回 `{"error_msg":"404 Route Not Found"}`(APISIX 的措辞)。
endpoint = "/api/web/llm/v2/chat/completions",
adapter = "openai",
-- 兜底目录(照参考 RACCOON_FALLBACK_MODELS)。此前 "raccoon-default" 是我编的。
-- 注意 sn- 前缀是本平台的真实命名约定(sensenova 系列)。
@ -3149,17 +3216,80 @@ local function parseRemoteModels(kind, body, provider)
end
end
elseif kind == "buddy_config" then
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
local d = rec.data or rec
local arr = d.models or d.agents or rec.models
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.id or m.modelId or m.name)
if type(id) == "string" and id ~= "" then out[#out+1] = id end
elseif kind == "buddy_config" then
-- 照参考 parseModelsFromConfig:结果在 data.models / data.agents 结构里。
-- 多形态兜底(顶层 data / 直接 models),因为该端点历史上变过形状。
--
-- ★★ 必须过滤「不可调用」的条目 —— 远端 data.models 里混着三类**不是对话
-- 模型**的东西,列进模型选择器只会让用户选中后报错(实测生产:glm-5.1
-- 与 glm-5.2 之外,codewise-* / nes-gf / hunyuan-image-alpha 同样调不通)。
-- 判据与命名逐条照参考 buddy.ts 的 isChatModel / isAutoSelectAlias:
-- ① 自动选择类内部别名:`auto` / `default`
-- ⚠️ 只按**这两个字面量**过滤,不做前缀匹配 —— 前缀会误伤
-- `default-model` / `fast-model`(国际版抽象别名,但被 craft agent
-- 引用,是官方推荐入口)。
-- ② 补全/NES 专用:前缀 `nes-` / `completion-` / `codewise-`,
-- 或带 `supportsExtra` 标记。参考实测 codewise-default-model-v2
-- 返回 `code 11102 model service info not found`(后端未开放)。
-- ③ 输出上限过小(>0 且 ≤256):补全用途,对话模型普遍 ≥24000。
-- ④ 带 `text-to-image` 标签的生成式模型(如 hunyuan-image-alpha)。
-- ⚠️ ③④ 需要元数据,故先把 models 建成 id→meta 映射。
local d = rec.data or rec
local arr = d.models or d.agents or rec.models
local metaById = {}
if type(arr) == "table" then
for _, m in ipairs(arr) do
if type(m) == "table" and type(m.id) == "string" and m.id ~= "" then
metaById[m.id] = m
end
end
end
end
-- 被 agent 引用的模型(craft/ask/plan/cli)。参考据此给 `agentReferenced`
-- 打标:`hy4-preview-f` 只由本端点下发且被 agent 引用,白名单重建会丢掉它。
local agentRef = {}
if type(d.agents) == "table" then
for _, ag in ipairs(d.agents) do
if type(ag) == "table" and type(ag.models) == "table" then
for _, mid in ipairs(ag.models) do
if type(mid) == "string" then agentRef[mid] = true end
end
end
end
end
local function buddyIsChatModel(id)
if id == "auto" or id == "default" then return false end
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
or id:sub(1, 9) == "codewise-" then return false end
local m = metaById[id]
if type(m) == "table" then
if m.supportsExtra == true then return false end
local mo = tonumber(m.maxOutputTokens)
if mo and mo > 0 and mo <= 256 then return false end
if type(m.tags) == "table" then
for _, t in ipairs(m.tags) do
if t == "text-to-image" then return false end
end
end
end
return true
end
local seen = {}
local function pushId(id)
if type(id) ~= "string" or id == "" then return end
if seen[id] or not buddyIsChatModel(id) then return end
seen[id] = true
out[#out + 1] = id
end
-- models 与 agents 两处都收(agents 里是被引用的清单,可能含远端
-- data.models 没有的新变体)。models 优先,保持顺序权威。
if type(arr) == "table" then
for _, m in ipairs(arr) do
local id = type(m) == "table" and (m.id or m.modelId or m.name) or nil
if type(id) == "string" then pushId(id) end
end
end
for id in pairs(agentRef) do pushId(id) end
elseif kind == "codearts_builtin" then
-- 照参考 fetchCodeArtsRemoteModels 的第 2 个端点:字段是 builtinModels
@ -3638,8 +3768,20 @@ local function syncAccountSource(provider, accountId)
models[#models+1] = { id = m, kind = "chat", priority = 20 }
end
end
-- 重建:先 remove(容错:源可能不存在)再 add
__llmsproxy_kernel.call("remove_source", { name = name })
-- 重建:先 remove(容错:源可能不存在)再 add。
-- ⚠ 这里失败**可容忍**:AddSource 是**同名 upsert**,add 会把旧的覆盖掉。
-- 但这正是它掩盖 remove_source 权限缺失的原因(纯删除路径才暴露),
-- 故失败仍记一笔(低频,不会刷屏)。
local rr, rerr = __llmsproxy_kernel.call("remove_source", { name = name })
if rr == nil or rr.ok ~= true then
local l = plugin.state.log
local last = type(l) == "table" and l[1] and l[1].msg or ""
-- 只在**上一次不是同一条**时记,避免每次同步都刷一条
if not tostring(last):find("撤源(重建)失败", 1, true) then
logEvent("撤源(重建)失败 " .. tostring(name) .. ": "
.. tostring(rerr or "(无错误信息)"))
end
end
local src = {
name = name,
base_url = plat.base_url,
@ -3678,8 +3820,29 @@ local function syncAccountSource(provider, accountId)
end
end
-- dropAccountSource:把某账号的上游源撤掉(账号停用/删除时调用)。
--
-- ★ 必须**检查返回值**:内核的 remove_source 需要**单独授予**(它是 Destructive 级,
-- 不在 add_source 的授权里)。生产权限集曾只有 add_source ⇒ 这个调用被**拒绝**,
-- 而调用方**忽略返回值** ⇒ 静默失败的正确后果是:**账号已删/已停用,它的源仍在
-- 对外服务**(用户看到"删了却还在用")。
-- 更隐蔽的是:AddSource 是**同名 upsert**,所以 syncAccountSource 里"先 remove 再 add"
-- 的 remove 失败**看不出来**(add 会把同名覆盖掉)—— 只有纯删除路径才暴露。
-- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。
local function dropAccountSource(provider, accountId)
__llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) })
local name = accountSourceName(provider, accountId)
-- ★ 内核桥是 `call(name, args) -> (result, nil) | (nil, err)`:
-- 失败时**第一个返回值是 nil**,原因在**第二个返回值**里。
-- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回",
-- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的
-- 诊断比没有更糟。两个返回值都要接。
local r, err = __llmsproxy_kernel.call("remove_source", { name = name })
if r == nil or r.ok ~= true then
logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err or "(无错误信息)")
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)")
return false, err
end
return true
end
-- ============================================================================
@ -11072,7 +11235,11 @@ function plugin.__inject_refresh_platform_probe(args)
label = id,
flow = "manual",
adapter = "openai",
base_url = "http://127.0.0.1:1/v1/chat/completions",
base_url = "http://127.0.0.1:1/v1",
-- 照正式平台同样的分工:base_url 只到路径前缀,路径放 endpoint
-- (内核 URL = base_url + endpoint)。测试夹具也照此写,
-- 免得将来有人把这行当范例复制进正式平台。
endpoint = "/chat/completions",
refresh = { style = tostring(args.style or "google"), url = tostring(args.refresh_url or "") },
models = { "m1" },
hosts = { "127.0.0.1" },
@ -11292,6 +11459,17 @@ function plugin.__resync_sources_probe(args)
return { json = { ok = true, n = n } }
end
-- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。
-- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、
-- 不在 exports 里),判据要断言"某件事被记录了"就得有它。
function plugin.__event_log_probe()
local out = {}
for _, e in ipairs(plugin.state.log or {}) do
if type(e) == "table" and type(e.msg) == "string" then out[#out + 1] = e.msg end
end
return { json = { log = out } }
end
-- plugin.__list_sources_probe:只给测试/诊断用 —— 内核里当前登记的源**及其顺序**。
-- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它,
-- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。