mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5923b3797 | |||
| 91f6d1f32f | |||
| c1048dcd4e |
@ -55,3 +55,31 @@ func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
|
||||
// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。
|
||||
//
|
||||
// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回
|
||||
// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加
|
||||
// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。
|
||||
// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。
|
||||
//
|
||||
// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。
|
||||
func TestBuddyCatalogDropsKnownUncallable(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
if !strings.Contains(s, "local NOT_CALLABLE = {") {
|
||||
t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)")
|
||||
}
|
||||
if !strings.Contains(s, `["hy4-preview-x"] = true`) {
|
||||
t.Error("hy4-preview-x 实测恒 11102,必须排除")
|
||||
}
|
||||
// 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后
|
||||
if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") {
|
||||
t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)")
|
||||
}
|
||||
t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸")
|
||||
}
|
||||
|
||||
190
internal/core/channels_log_panel_test.go
Normal file
190
internal/core/channels_log_panel_test.go
Normal file
@ -0,0 +1,190 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 插件日志面板:级别 + 归属 + 同文折叠(否则用户动作被 96% 的周期噪音淹没)。
|
||||
//
|
||||
// 真实问题(生产实测):50 条日志里 **48 条(96%)** 是三个定时任务
|
||||
// (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
|
||||
// 的重复回音,唯一模式只有 8 种 ⇒ 约 2 小时就把 50 条环形缓冲冲满一轮,
|
||||
// **真正的用户动作**(如「账号删除」)被挤到看不见。
|
||||
//
|
||||
// 三个结构性修法,每个都在源码里可断言:
|
||||
//
|
||||
// ① logEvent 支持 level(task/warn/error),前端默认折叠 task
|
||||
// ② logEvent 支持 platform,前端按选中渠道过滤(修"点 buddy 看到 trae"的归属错误)
|
||||
// ③ 同文折叠:紧邻同文不新增条目,只 n=n+1
|
||||
func TestChannelsLogEventCarriesLevelAndPlatform(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 签名必须收 level 与 platform(三参)
|
||||
if !regexp.MustCompile(`local function logEvent\(msg, level, platform\)`).MatchString(s) {
|
||||
t.Error("logEvent 必须接收 (msg, level, platform) —— 否则级别与渠道归属都无从表达")
|
||||
}
|
||||
// ② 写入的条目必须带上这两个字段
|
||||
for _, field := range []string{"level = level or", "platform = platform"} {
|
||||
if !strings.Contains(s, field) {
|
||||
t.Errorf("日志条目必须写入 %s", field)
|
||||
}
|
||||
}
|
||||
// ③ 同文折叠:紧邻同文时只加计数
|
||||
if !strings.Contains(s, "top.n = (top.n or 1) + 1") {
|
||||
t.Error("必须实现紧邻同文折叠(否则周期任务的重复回音会把 50 条缓冲冲满)")
|
||||
}
|
||||
// ④ 折叠必须只针对**紧邻**条目 —— 否则一条每小时出现的消息会不断跳到最前,
|
||||
// 时间线失去意义。断言比较对象是 log[1](栈顶)而不是任意条目。
|
||||
if !strings.Contains(s, "local top = log[1]") {
|
||||
t.Error("折叠必须只比较**紧邻**的上一条(log[1]);对任意条目折叠会让时间线错乱")
|
||||
}
|
||||
t.Log("✓ logEvent 具备 level / platform / 紧邻同文折叠")
|
||||
}
|
||||
|
||||
// ★ 周期任务的**常规**结果必须是 task 级(默认折叠),异常必须是 warn/error。
|
||||
//
|
||||
// 判据咬的是"这些具体调用点被降级了",而不是"存在 task 这个词" ——
|
||||
// 后者只要有一处就为真,测不出"某个噪音源忘了降级"。
|
||||
func TestChannelsPeriodicTaskLogsAreDowngraded(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// 这些是「每次跑都成功」的常规回音 —— 必须 task 级
|
||||
routine := []string{
|
||||
`logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")`,
|
||||
`logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")`,
|
||||
`logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")`,
|
||||
}
|
||||
for _, r := range routine {
|
||||
if !strings.Contains(s, r) {
|
||||
t.Errorf("周期任务的常规结果必须降级为 task(否则会淹没用户动作):\n %s", r)
|
||||
}
|
||||
}
|
||||
// 这些是异常 —— 必须 warn(不能被一起折叠掉)
|
||||
for _, pat := range []string{
|
||||
`logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")`,
|
||||
`logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")`,
|
||||
`logEvent("凭据续期失败 " .. #failed .. " 个:`,
|
||||
} {
|
||||
if !strings.Contains(s, pat) {
|
||||
t.Errorf("异常必须保持 warn(不能被当常规噪音折叠):%s", pat)
|
||||
}
|
||||
}
|
||||
// 「已同步源」是周期重同步的常规回音 —— 降级为 task
|
||||
if !strings.Contains(s, `logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)`) {
|
||||
t.Error("「已同步源」是周期重同步的常规回音,必须 task 级且带平台归属")
|
||||
}
|
||||
t.Log("✓ 周期噪音已降级,异常保持 warn")
|
||||
}
|
||||
|
||||
// ★ 前端的三个过滤维度必须都在(级别门槛 / 例行折叠 / 按渠道过滤)。
|
||||
func TestChannelsLogPanelHasFilters(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 日志卡片必须**独立于渠道详情**(C 的核心:修正归属)
|
||||
// 判据:日志卡片的 DOM 不是 ch-detail 的子元素。
|
||||
// 做法:取 ch-detail 开始到 ch-log-card 之间的片段,要求中间出现过
|
||||
// ch-detail 的闭合(即两者是兄弟而非父子)。
|
||||
iDetail := strings.Index(s, `id="ch-detail"`)
|
||||
iLogCard := strings.Index(s, `id="ch-log-card"`)
|
||||
if iDetail < 0 || iLogCard < 0 {
|
||||
t.Fatal("找不到 ch-detail 或 ch-log-card(判据需更新)")
|
||||
}
|
||||
if iLogCard < iDetail {
|
||||
t.Fatal("运行日志卡片应在渠道详情**之后**(作为兄弟节点)")
|
||||
}
|
||||
// 统计 ch-detail 打开到日志卡片之间未闭合的 div 层数
|
||||
seg := s[iDetail:iLogCard]
|
||||
depth := 0
|
||||
for _, m := range regexp.MustCompile(`</?div`).FindAllString(seg, -1) {
|
||||
if strings.HasPrefix(m, "</") {
|
||||
depth--
|
||||
} else {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if depth > 0 {
|
||||
t.Errorf("运行日志卡片仍嵌在渠道详情内部(depth=%d)⇒ 点开某渠道会看到别的渠道的日志(归属错误)", depth)
|
||||
}
|
||||
|
||||
// ② 三个过滤维度
|
||||
for _, id := range []string{`id="ch-log-verbose"`, `id="ch-log-level"`} {
|
||||
if !strings.Contains(s, id) {
|
||||
t.Errorf("缺少日志视图控件 %s", id)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s, "function chLogRender()") {
|
||||
t.Error("必须实现 chLogRender()(否则过滤逻辑无处落地)")
|
||||
}
|
||||
// ③ 按渠道过滤 + 切渠道重渲染
|
||||
if !strings.Contains(s, "if (selPlat) return e.platform === selPlat;") {
|
||||
t.Error("必须按当前选中渠道过滤日志(否则归属错误以另一种形式回来)")
|
||||
}
|
||||
if !strings.Contains(s, "chLogRender();\n };\n") {
|
||||
t.Error("chSelect 切换渠道后必须重渲染日志")
|
||||
}
|
||||
t.Log("✓ 日志面板独立成卡片,三个过滤维度齐备")
|
||||
}
|
||||
|
||||
// ★ 日志截断必须按**字符**切,不能按字节 —— 否则中文被切出无效 UTF-8。
|
||||
//
|
||||
// 真实缺陷(生产实测,v2.0.33 截图里可见):日志尾显示成
|
||||
//
|
||||
// `能力增强不<E5BCBA>`
|
||||
//
|
||||
// 根因:`tostring(cerr):sub(1, 120)` —— Lua 的 sub 按**字节**切,而中文占 3 字节
|
||||
// ⇒ 切在字符中间产生替换字符(U+FFFD)。一句本来完整的诊断看起来像数据损坏。
|
||||
func TestLogTruncationIsUTF8Safe(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
// ① 必须存在按字符截断的助手
|
||||
if !strings.Contains(s, "plugin.utf8Clip = function(s, maxBytes)") {
|
||||
t.Fatal("必须实现 plugin.utf8Clip(按字符截断)")
|
||||
}
|
||||
// ② 中文文案上的截断不得再用裸 sub()
|
||||
for _, bad := range []string{
|
||||
`tostring(cres):sub(1, 120)`,
|
||||
`tostring(cerr):sub(1, 120)`,
|
||||
`table.concat(failed, "; "):sub(1, 300)`,
|
||||
} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("中文文案的截断不能用裸 sub()(按字节切会产生 U+FFFD):%s", bad)
|
||||
}
|
||||
}
|
||||
// ③ 三处必须已换成 utf8Clip
|
||||
for _, want := range []string{
|
||||
"plugin.utf8Clip(cres, 120)",
|
||||
"plugin.utf8Clip(cerr, 120)",
|
||||
`plugin.utf8Clip(table.concat(failed, "; "), 300)`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("缺少 UTF-8 安全截断调用:%s", want)
|
||||
}
|
||||
}
|
||||
// ④ 助手必须**只在超长时**才截断(否则正常文案会被莫名缩短)
|
||||
if !strings.Contains(s, "if #s <= maxBytes then return s end") {
|
||||
t.Error("utf8Clip 必须对未超长的输入原样返回")
|
||||
}
|
||||
// ⑤ 回退逻辑必须处理续字节(10xxxxxx)与孤立首字节
|
||||
if !strings.Contains(s, "elseif b >= 0xC0 then") {
|
||||
t.Error("utf8Clip 必须回退孤立的 UTF-8 首字节(否则末尾仍是半个字符)")
|
||||
}
|
||||
t.Log("✓ 截断已 UTF-8 安全")
|
||||
}
|
||||
38
internal/core/channels_utf8clip_test.go
Normal file
38
internal/core/channels_utf8clip_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 行为验证(不只是静态断言):utf8Clip 对中文截断后必须是合法 UTF-8。
|
||||
func TestUtf8ClipBehaviour(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := "能力元数据源 5.6MB 超过内核 4MiB 响应上限(刻意的堆保护,不为此放宽)—— 能力增强不可用,已退回兜底表"
|
||||
for _, n := range []int{10, 40, 119, 120, 121, 400} {
|
||||
out, err := ps.Call("channels", "__utf8_clip_probe", map[string]interface{}{"s": s, "n": n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := jsonOf(t, out)["out"].(string)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("n=%d 结果不是合法 UTF-8: %q", n, got)
|
||||
}
|
||||
if n < len(s) && len(got) > n {
|
||||
t.Errorf("n=%d 结果超出字节上限: %d", n, len(got))
|
||||
}
|
||||
if n >= len(s) && got != s {
|
||||
t.Errorf("n=%d 未超长时应原样返回", n)
|
||||
}
|
||||
t.Logf("n=%-4d → %q (valid=%v, bytes=%d)", n, got, utf8.ValidString(got), len(got))
|
||||
}
|
||||
}
|
||||
116
internal/lua/adapter_buddy_test.go
Normal file
116
internal/lua/adapter_buddy_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadBuddyAdapter 装载内置 buddy 适配器。
|
||||
func loadBuddyAdapter(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/buddy.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("buddy", string(src)); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ buddy 适配器:两个厂商要求,各有实测依据。
|
||||
//
|
||||
// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150)
|
||||
// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0)
|
||||
func TestBuddyAdapterEffortAndHeaders(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
|
||||
// ① glm-5.1 → 补 medium
|
||||
out, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &b); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if b["reasoning_effort"] != "medium" {
|
||||
t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ② 其它模型**不补**(避免把单模型要求扩成全平台行为)
|
||||
out2, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b2 map[string]interface{}
|
||||
json.Unmarshal([]byte(out2), &b2)
|
||||
if b2["reasoning_effort"] != nil {
|
||||
t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ③ 调用方已给档位时**尊重它**
|
||||
out3, _ := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`)
|
||||
var b3 map[string]interface{}
|
||||
json.Unmarshal([]byte(out3), &b3)
|
||||
if b3["reasoning_effort"] != "high" {
|
||||
t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ④ 头:Accept 必须是 SSE
|
||||
h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h["Accept"] != "text/event-stream" {
|
||||
t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"])
|
||||
}
|
||||
for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} {
|
||||
if h[k] == "" {
|
||||
t.Errorf("缺少 buddy 身份头 %s", k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"])
|
||||
}
|
||||
|
||||
// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。
|
||||
func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}`
|
||||
out, err := vm.Transform("buddy", "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) {
|
||||
t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归护栏:适配器**必须**定义全部四个转换函数。
|
||||
//
|
||||
// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码",
|
||||
// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统),
|
||||
// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request,
|
||||
// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文,
|
||||
// 网关却记 completion_tokens=0、客户端看到**空回复**。
|
||||
// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底
|
||||
// "看起来正常" ⇒ 症状指向错误方向。
|
||||
//
|
||||
// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话"
|
||||
//
|
||||
// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。
|
||||
func TestBuddyAdapterDefinesAllTransforms(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} {
|
||||
if !vm.HasFn("buddy", fn) {
|
||||
t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 四个转换函数齐备")
|
||||
}
|
||||
201
internal/lua/adapters/buddy.lua
Normal file
201
internal/lua/adapters/buddy.lua
Normal file
@ -0,0 +1,201 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "buddy"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
|
||||
-- to the Anthropic Messages API internally, so they inherit Anthropic's
|
||||
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
|
||||
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
|
||||
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
|
||||
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
|
||||
-- is replayed to every other source, so one such id kills every Claude slot at
|
||||
-- once and an AUTO request falls through all tiers.
|
||||
--
|
||||
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
|
||||
-- matching tool_call_id are rewritten identically within one request. A
|
||||
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
|
||||
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
|
||||
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
|
||||
-- well-behaved traffic is byte-identical to before.
|
||||
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
|
||||
local TOOL_ID_MAX = 64
|
||||
|
||||
local function safe_tool_id(id)
|
||||
if type(id) ~= "string" or id == "" then return id end
|
||||
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
|
||||
if clean == id and #clean <= TOOL_ID_MAX then
|
||||
return clean
|
||||
end
|
||||
local digest = string.sub(sha256_hex(id), 1, 8)
|
||||
local keep = TOOL_ID_MAX - #digest - 1
|
||||
if #clean > keep then clean = string.sub(clean, 1, keep) end
|
||||
return clean .. "_" .. digest
|
||||
end
|
||||
|
||||
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
|
||||
--
|
||||
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
|
||||
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
|
||||
local NEED_EFFORT = {
|
||||
["glm-5.1"] = "medium",
|
||||
}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- The gateway's private control fields are GONE from the body (v2): the
|
||||
-- neutral `thinking` field carries that intent and each adapter maps it in
|
||||
-- its own protocol terms. These upstreams have no thinking switch at all,
|
||||
-- so the field is simply dropped here — which is what the nil-ing below
|
||||
-- used to do, except the field being dropped is no longer the kernel's
|
||||
-- private one.
|
||||
req.thinking = nil
|
||||
req.extra_body = nil
|
||||
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
|
||||
-- 否则上游回 400:
|
||||
-- {"code":11150,"msg":"the reasoning effort value is not supported
|
||||
-- by the current model"}
|
||||
-- 实测(2026-10-16,逐字对照实验):
|
||||
-- glm-5.1 裸(无该字段) → 400 11150 ✗
|
||||
-- glm-5.1 + reasoning_effort:medium → 200 ✓
|
||||
-- glm-5.1 空串 → 400 11150 ✗
|
||||
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
|
||||
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
|
||||
-- glm-5.1 **独有**的要求。
|
||||
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
|
||||
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
|
||||
-- 所有模型。
|
||||
if NEED_EFFORT[req.model or ""] and
|
||||
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
|
||||
req.reasoning_effort == nil then
|
||||
req.reasoning_effort = NEED_EFFORT[req.model]
|
||||
end
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
if msg.tool_call_id ~= nil then
|
||||
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
|
||||
end
|
||||
if type(msg.tool_calls) == "table" then
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- usage-only chunk is not a content/finish signal; the gateway
|
||||
-- emits its own terminal stop chunk and merges this usage.
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- Sanitize on the way OUT too: an id this upstream happily minted (it
|
||||
-- does not validate them) becomes a landmine once the client replays
|
||||
-- it to a Claude upstream. Only the first fragment of a streamed call
|
||||
-- carries an id; later argument fragments have none and are untouched.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
|
||||
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
|
||||
--
|
||||
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
|
||||
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
|
||||
-- "模型不回答",极易误判成模型坏了或额度不足。
|
||||
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
|
||||
--
|
||||
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
|
||||
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
|
||||
-- 是产品码(codebuddy)。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Accept"] = "text/event-stream",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
|
||||
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
|
||||
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. meta.api_key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -64,7 +64,7 @@ plugin.kernel = {
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -183,7 +183,7 @@ local PLATFORMS = {
|
||||
adapter = "channels-trae",
|
||||
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
|
||||
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
|
||||
models = {
|
||||
models = {
|
||||
"DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430",
|
||||
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
|
||||
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
|
||||
@ -435,7 +435,11 @@ local PLATFORMS = {
|
||||
headers_fn = true,
|
||||
parse = "buddy_config",
|
||||
},
|
||||
adapter = "openai",
|
||||
-- ★ 用内置 buddy 适配器(而非通用 openai):buddy 的 glm-5.1 要求请求体里
|
||||
-- 必须有非空 reasoning_effort(否则 400 + 11150),且流式请求必须带
|
||||
-- Accept: text/event-stream(否则上游只回空帧、completion_tokens=0)。
|
||||
-- 该适配器复用 openai 的全部转换,只补这两处厂商要求。
|
||||
adapter = "buddy",
|
||||
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
|
||||
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
|
||||
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
@ -461,7 +465,10 @@ local PLATFORMS = {
|
||||
return h
|
||||
end,
|
||||
models = {
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
|
||||
-- 兜底目录(远端不可用时才用)。照参考 CODEBUDDY_FALLBACK_MODELS 的取舍:
|
||||
-- **只列实测可调用的**。hy4-preview-x 已实测恒 11102(后端未开放)故剔除
|
||||
-- —— 参考的兜底表里同样没有它(它靠白名单达到同样效果)。
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview",
|
||||
"glm-5.3", "glm-5.3-flash", "glm-5.2",
|
||||
},
|
||||
-- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用
|
||||
@ -2772,9 +2779,70 @@ do
|
||||
if sh then sh:close() end
|
||||
end
|
||||
|
||||
local function logEvent(msg)
|
||||
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
|
||||
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
|
||||
-- utf8Clip 按**字符**截断(不是字节),并保证结果仍是合法 UTF-8。
|
||||
--
|
||||
-- ★ 为什么不能用 `s:sub(1, N)`:Lua 的 sub 按**字节**切,而中文字符占 3 字节
|
||||
-- ⇒ 切在字符中间就产生**无效 UTF-8**,前端显示成 `能力增强不<E5BCBA>` 这种乱码尾巴。
|
||||
-- 实测(生产能力元数据那条)就是这样 —— 一句本来完整的诊断被切出替换字符,
|
||||
-- 看起来像数据损坏,实际是截断方式错。
|
||||
--
|
||||
-- 做法:先按字节切,再回退到最后一个完整 UTF-8 序列边界。
|
||||
-- 只在必要时回退(最多退 2 字节),所以不会显著缩短内容。
|
||||
plugin.utf8Clip = function(s, maxBytes)
|
||||
s = tostring(s or "")
|
||||
if #s <= maxBytes then return s end
|
||||
local cut = s:sub(1, maxBytes)
|
||||
-- 回退掉尾部不完整的 UTF-8 续字节(10xxxxxx)与被切半的首字节
|
||||
for _ = 1, 4 do
|
||||
local b = cut:byte(-1)
|
||||
if not b then break end
|
||||
if b < 0x80 then break -- ASCII,完整
|
||||
elseif b >= 0xC0 then -- 首字节但没有续字节收尾 ⇒ 整字符不完整
|
||||
cut = cut:sub(1, -2)
|
||||
break
|
||||
else -- 续字节,继续往前退
|
||||
cut = cut:sub(1, -2)
|
||||
end
|
||||
end
|
||||
return cut
|
||||
end
|
||||
|
||||
-- logEvent 写一条插件事件日志(带级别与渠道归属)。
|
||||
--
|
||||
-- ★ 为什么需要「级别 + 折叠」,而不是继续往一个裸数组里追加:
|
||||
-- 实测生产 50 条日志里 **48 条(96%)** 是三个定时任务
|
||||
-- (refresh_models 2h / auto_claim 2h / refresh_creds 0.5h,全为 first_due_now)
|
||||
-- 的重复回音,唯一模式只有 8 种 ⇒ 约 **2 小时**就把 50 条环形缓冲冲满一轮,
|
||||
-- **真正的用户动作**(如「账号删除」)被挤到看不见 —— 面板沦为
|
||||
-- 「最近两小时的定时任务回音」。
|
||||
--
|
||||
-- 三条措施:
|
||||
-- ① `level` ∈ info|task|warn|error。定时任务的**常规**结果记 "task"
|
||||
-- (前端默认折叠),只有异常才 warn/error。
|
||||
-- ② **紧邻同文折叠**:与**上一条**完全同文时不新增条目,只 n=n+1
|
||||
-- 并刷新时间戳。周期任务的连续回音于是合并成一条(显示 ×9),
|
||||
-- 而无需给每个调用点判定「这次结果变了没」(那要引入状态比较)。
|
||||
-- ⚠️ 只在**紧邻**时折叠:否则一条每小时出现的消息会不断"跳"到最前,
|
||||
-- 时间线就失去意义。
|
||||
-- ③ `platform`:记录归属,前端按当前选中渠道过滤。
|
||||
-- ⚠️ 历史 state 里的老条目没有这个字段,前端必须容忍 nil。
|
||||
local function logEvent(msg, level, platform)
|
||||
local log = plugin.state.log
|
||||
local now = os.time and os.time() or 0
|
||||
local top = log[1]
|
||||
if top and top.msg == msg and (top.level or "info") == (level or "info") then
|
||||
top.n = (top.n or 1) + 1
|
||||
top.at = now
|
||||
markDirty()
|
||||
return
|
||||
end
|
||||
table.insert(log, 1, {
|
||||
at = now,
|
||||
msg = msg,
|
||||
level = level or "info",
|
||||
platform = platform,
|
||||
})
|
||||
while #log > 50 do table.remove(log) end
|
||||
markDirty()
|
||||
end
|
||||
|
||||
@ -3256,7 +3324,23 @@ local function parseRemoteModels(kind, body, provider)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ★ 已实测**不可调用**的模型(上游恒回 11102 "model service info not found")。
|
||||
--
|
||||
-- 为什么需要它:这类模型既**不是**前缀可判(不像 nes-/codewise-),也**不在**
|
||||
-- agent 引用清单之外 —— 实测 `hy4-preview-x` 被服务端 agents 引用,
|
||||
-- 于是会被下面的 agentRef 例外重新放回目录,用户选中就 400。
|
||||
-- 参考的做法是**白名单重建**(只保留编译期实测过的 id),本仓没有那份表,
|
||||
-- 故用等价的**黑名单**精确排除:只放实测确认调不通的,不做任何猜测。
|
||||
--
|
||||
-- ⚠️ 判据来源必须是**真实请求**,不是元数据:
|
||||
-- 实测 hy4-preview-x 加/不加 reasoning_effort 都是 11102 ⇒ 与 effort 无关,
|
||||
-- 是后端未开放(与 glm-5.1 的 11150 是**不同**原因,后者已被 adapter 修好)。
|
||||
-- 参考 product.ts 的兜底白名单里同样**没有** hy4-preview-x。
|
||||
local NOT_CALLABLE = {
|
||||
["hy4-preview-x"] = true,
|
||||
}
|
||||
local function buddyIsChatModel(id)
|
||||
if NOT_CALLABLE[id] then return false end
|
||||
if id == "auto" or id == "default" then return false end
|
||||
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
|
||||
or id:sub(1, 9) == "codewise-" then return false end
|
||||
@ -3288,6 +3372,8 @@ local function parseRemoteModels(kind, body, provider)
|
||||
if type(id) == "string" then pushId(id) end
|
||||
end
|
||||
end
|
||||
-- agentRef 的例外也必须过 NOT_CALLABLE 闸:否则"被 agent 引用"会把
|
||||
-- 实测不可调的模型重新放回目录(hy4-preview-x 正是这样漏进来的)。
|
||||
for id in pairs(agentRef) do pushId(id) end
|
||||
|
||||
|
||||
@ -3779,7 +3865,7 @@ local function syncAccountSource(provider, accountId)
|
||||
-- 只在**上一次不是同一条**时记,避免每次同步都刷一条
|
||||
if not tostring(last):find("撤源(重建)失败", 1, true) then
|
||||
logEvent("撤源(重建)失败 " .. tostring(name) .. ": "
|
||||
.. tostring(rerr or "(无错误信息)"))
|
||||
.. tostring(rerr or "(无错误信息)"), "warn", provider)
|
||||
end
|
||||
end
|
||||
local src = {
|
||||
@ -3812,11 +3898,11 @@ local function syncAccountSource(provider, accountId)
|
||||
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
|
||||
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
|
||||
if aerr then
|
||||
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr))
|
||||
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider)
|
||||
elseif r and r.json and r.json.ok == false then
|
||||
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error))
|
||||
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider)
|
||||
else
|
||||
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)")
|
||||
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)
|
||||
end
|
||||
end
|
||||
|
||||
@ -3839,7 +3925,8 @@ local function dropAccountSource(provider, accountId)
|
||||
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)")
|
||||
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)",
|
||||
"error", provider)
|
||||
return false, err
|
||||
end
|
||||
return true
|
||||
@ -6550,7 +6637,7 @@ function plugin.refresh_models()
|
||||
end
|
||||
-- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误)
|
||||
if cooled > 0 then
|
||||
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台")
|
||||
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")
|
||||
end
|
||||
-- 顺带刷新模型能力元数据(照参考 opencode-capability)。
|
||||
-- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。
|
||||
@ -6558,19 +6645,27 @@ function plugin.refresh_models()
|
||||
if not cok then
|
||||
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
|
||||
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
|
||||
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120))
|
||||
logEvent("能力元数据刷新异常: " .. plugin.utf8Clip(cres, 120), "warn")
|
||||
elseif cres == true then
|
||||
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)")
|
||||
logEvent("模型能力元数据已刷新(" .. tostring(cerr or 0) .. " 条)", "task")
|
||||
elseif cerr then
|
||||
logEvent("能力元数据未刷新: " .. tostring(cerr):sub(1, 120))
|
||||
logEvent("能力元数据未刷新: " .. plugin.utf8Clip(cerr, 120), "warn")
|
||||
end
|
||||
if updated > 0 then
|
||||
logEvent("模型目录已刷新(" .. updated .. " 个平台)")
|
||||
logEvent("模型目录已刷新(" .. updated .. " 个平台)", "task")
|
||||
end
|
||||
-- 失败留痕但**不清缓存**(refreshRemoteModels 内部已保证):
|
||||
-- 拉不到时继续用上次的好数据,绝不能把目录清空。
|
||||
if #failed > 0 then
|
||||
logEvent("模型刷新失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 200))
|
||||
-- ★ 逐平台各记一条(而不是把 N 个挤成一条汇总):这样前端按渠道过滤时,
|
||||
-- 点开 trae 就只看到 trae 的刷新失败。原先挤成一条汇总是**归属丢失**的
|
||||
-- 一个来源 —— 它既不属于 trae 也不属于 minimax,于是只能在「全部渠道」里
|
||||
-- 出现,点进任何单渠道都看不到自己的问题。
|
||||
for _, f in ipairs(failed) do
|
||||
-- failed 项形如 "<platform>: <err>",拆出平台名做归属
|
||||
local pid = tostring(f):match("^([%w_%-]+):") or nil
|
||||
logEvent("模型刷新失败 " .. tostring(f), "warn", pid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -6619,9 +6714,9 @@ function plugin.refresh_creds()
|
||||
end
|
||||
end
|
||||
end
|
||||
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个") end
|
||||
if refreshed > 0 then logEvent("凭据已续期 " .. refreshed .. " 个", "task") end
|
||||
if #failed > 0 then
|
||||
logEvent("凭据续期失败 " .. #failed .. " 个:" .. table.concat(failed, "; "):sub(1, 300))
|
||||
logEvent("凭据续期失败 " .. #failed .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn")
|
||||
end
|
||||
end
|
||||
|
||||
@ -10738,8 +10833,33 @@ plugin.ui = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:16px 0 6px;font-size:13px">最近日志</h3>
|
||||
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px"></ul>
|
||||
<!-- 运行日志:**全局**,不是某个渠道的。
|
||||
★ 为什么必须独立成卡片(而不是留在上面「点开某渠道才显示」的渠道详情里):
|
||||
日志源 plugin.state.log 是**单一全局数组**(logEvent 写入的条目原先连
|
||||
platform 字段都没有),把它渲染在渠道详情卡里属于**归属错误** ——
|
||||
用户点开 buddy,看到的却是 trae/minimax/qoder 的日志混在一起,无从区分。
|
||||
现在:本卡片常驻;条目带 platform 时按当前选中渠道过滤,不带 platform 的
|
||||
全局条目(定时任务汇总等)只在「全部渠道」视图显示。 -->
|
||||
</div>
|
||||
<div class="card" id="ch-log-card">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<b style="font-size:15px">运行日志</b>
|
||||
<span class="grow"></span>
|
||||
<label class="muted" style="font-size:12px;display:inline-flex;align-items:center;gap:5px;white-space:nowrap">
|
||||
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()" style="margin:0"> 显示例行任务
|
||||
</label>
|
||||
<select id="ch-log-level" onchange="chLogRender()"
|
||||
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
|
||||
<option value="all">全部级别</option>
|
||||
<option value="warn">警告及以上</option>
|
||||
<option value="error">仅错误</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="muted" style="font-size:12px;margin:8px 0" id="ch-log-hint"></div>
|
||||
<!-- ★ 文本**允许换行**(word-break),否则长诊断在窄卡片里被截断,
|
||||
而截断的尾巴正是最该看到的部分(如 HTTP 状态/具体原因)。 -->
|
||||
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto;word-break:break-word;line-height:1.5"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
@ -10757,15 +10877,76 @@ plugin.ui = {
|
||||
}
|
||||
// POST 端点统一走 call(path,"POST",...);账号/模型/供应商操作都按这个形状。
|
||||
let state = { statuses: [], filter: "all", search: "", selected: undefined,
|
||||
accounts: undefined, models: undefined, login: null, pollTimer: null };
|
||||
accounts: undefined, models: undefined, login: null, pollTimer: null,
|
||||
// 日志:原始条目 + 用户的两个视图开关(级别门槛、是否显示例行任务)
|
||||
log: [], logLevel: "all", logVerbose: false };
|
||||
const stopPoll = () => { if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; } };
|
||||
|
||||
// ── 运行日志的渲染(级别门槛 + 例行任务折叠 + 按渠道过滤)──
|
||||
//
|
||||
// 三个过滤维度,各自解决一个实测问题:
|
||||
// ① **级别**:`task` 是定时任务的常规结果。实测 50 条里 48 条(96%)都是它,
|
||||
// 默认**不显示**,否则真正的用户动作被淹没。warn/error 永远显示。
|
||||
// ② **按渠道**:条目带 `platform` 时只在对应渠道视图显示。这是因为
|
||||
// 日志本身是全局的(单一数组),若不加区分,点开 buddy 会看到 trae 的事。
|
||||
// 不带 platform 的条目(跨平台汇总,如"模型目录已刷新(N 个平台)")
|
||||
// 只在「全部渠道」视图出现 —— 它们本来就不属于任何单一渠道。
|
||||
// ③ **同文折叠计数**:后端把紧邻同文合并成一条并给 n>1(见 logEvent),
|
||||
// 这里显示成 `×N`,既保留"重复了多少次"的信息又不刷屏。
|
||||
function chLogRender() {
|
||||
const ul = q("#ch-log"); if (!ul) return;
|
||||
const verbose = q("#ch-log-verbose");
|
||||
const sel = q("#ch-log-level");
|
||||
state.logVerbose = verbose ? verbose.checked : state.logVerbose;
|
||||
state.logLevel = sel ? sel.value : state.logLevel;
|
||||
|
||||
const rank = { info: 1, task: 1, warn: 2, error: 3 };
|
||||
const minRank = state.logLevel === "error" ? 3 : state.logLevel === "warn" ? 2 : 1;
|
||||
const selPlat = state.selected;
|
||||
|
||||
const rows = (state.log || []).filter((e) => {
|
||||
const lvl = e.level || "info";
|
||||
// 「显示例行任务」勾上时,task 视同 info 一并显示
|
||||
const eff = (lvl === "task" && state.logVerbose) ? "info" : lvl;
|
||||
if ((rank[eff] || 1) < minRank) return false;
|
||||
// 渠道过滤:选中某渠道时,只显示归属于它、或没有归属的全局条目?
|
||||
// 不 —— 选中渠道时应**只**显示该渠道的条目,否则"点 buddy 看到 trae"的
|
||||
// 归属错误会以另一种形式回来。全局条目留给「全部渠道」视图。
|
||||
if (selPlat) return e.platform === selPlat;
|
||||
return true;
|
||||
});
|
||||
|
||||
const hidden = (state.log || []).length - rows.length;
|
||||
const hint = q("#ch-log-hint");
|
||||
if (hint) {
|
||||
const parts = [];
|
||||
parts.push(selPlat ? ("当前渠道:" + esc(selPlat)) : "全部渠道");
|
||||
if (!state.logVerbose) parts.push("例行任务已折叠");
|
||||
if (hidden > 0) parts.push("另有 " + hidden + " 条被过滤");
|
||||
hint.innerHTML = parts.join(" · ");
|
||||
}
|
||||
|
||||
ul.innerHTML = rows.map((e) => {
|
||||
const lvl = e.level || "info";
|
||||
const ts = e.at ? new Date(e.at * 1000).toLocaleString() : "";
|
||||
const badge = lvl === "error" ? '<b style="color:#e5534b">错误</b> '
|
||||
: lvl === "warn" ? '<b style="color:#d29922">警告</b> '
|
||||
: lvl === "task" ? '<span class="muted">例行</span> ' : "";
|
||||
const n = (e.n || 1) > 1 ? ' <span class="muted">×' + e.n + "</span>" : "";
|
||||
const del = String(e.msg || "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
return '<li style="margin-bottom:3px">' + badge +
|
||||
'<span class="muted" style="font-size:11px">' + ts + "</span> " + del + n + "</li>";
|
||||
}).join("") || '<li class="muted">(无匹配日志)</li>';
|
||||
}
|
||||
window.chLogRender = chLogRender;
|
||||
|
||||
async function loadStatuses() {
|
||||
const j = await call("channels/provider/status", "POST", {});
|
||||
state.statuses = j.statuses || [];
|
||||
renderSummary(); renderGrid();
|
||||
const sj = await call("channels/status");
|
||||
const log = q("#ch-log"); if (log) log.innerHTML = (sj.log || []).map((e) => "<li>" + esc(e.msg) + "</li>").join("");
|
||||
state.log = sj.log || [];
|
||||
chLogRender();
|
||||
const aj = await call("channels/auto_checkin", "POST", {});
|
||||
q("#ch-auto").checked = aj.autoCheckin !== false;
|
||||
}
|
||||
@ -10896,7 +11077,13 @@ plugin.ui = {
|
||||
// ---- 动作 ----
|
||||
window.chFilter = (f) => { state.filter = f; renderSummary(); renderGrid(); };
|
||||
q("#ch-search").addEventListener("input", (e) => { state.search = e.target.value.toLowerCase(); renderGrid(); });
|
||||
window.chSelect = async (id) => { state.selected = id; await renderDetail(); };
|
||||
window.chSelect = async (id) => {
|
||||
state.selected = id;
|
||||
await renderDetail();
|
||||
// 切渠道要重渲染日志:日志条目带 platform,视图按当前选中渠道过滤
|
||||
// (不重渲染的话,点开 buddy 仍显示上一个渠道的日志)。
|
||||
chLogRender();
|
||||
};
|
||||
window.chAcct = async (aid, act) => {
|
||||
const id = state.selected; if (!id) return;
|
||||
if (act === "delete" && !confirm("删除账号 " + aid + "?(连同凭据与上游源)")) return;
|
||||
@ -11459,6 +11646,13 @@ function plugin.__resync_sources_probe(args)
|
||||
return { json = { ok = true, n = n } }
|
||||
end
|
||||
|
||||
-- plugin.__utf8_clip_probe:只给测试用 —— 直接验证 utf8Clip 的语义
|
||||
-- (截断后仍是合法 UTF-8、未超长时原样返回)。静态断言看不出这些性质。
|
||||
function plugin.__utf8_clip_probe(args)
|
||||
args = args or {}
|
||||
return { json = { out = plugin.utf8Clip(args.s, tonumber(args.n) or 120) } }
|
||||
end
|
||||
|
||||
-- plugin.__event_log_probe:只给测试/诊断用 —— 插件事件日志的文案列表。
|
||||
-- logEvent 写进 plugin.state.log,但它没有对外的读出口(`status` 是 HTTP 路由、
|
||||
-- 不在 exports 里),判据要断言"某件事被记录了"就得有它。
|
||||
|
||||
Reference in New Issue
Block a user