mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1048dcd4e | |||
| 2b8fcaf434 | |||
| 47fe759025 |
57
internal/core/channels_buddy_catalog_filter_test.go
Normal file
57
internal/core/channels_buddy_catalog_filter_test.go
Normal 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 类别名")
|
||||
}
|
||||
140
internal/core/channels_log_panel_test.go
Normal file
140
internal/core/channels_log_panel_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
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("能力元数据未刷新: " .. tostring(cerr):sub(1, 120), "warn")`,
|
||||
`logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 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("✓ 日志面板独立成卡片,三个过滤维度齐备")
|
||||
}
|
||||
112
internal/lua/adapter_opencodezen_reasoning_test.go
Normal file
112
internal/lua/adapter_opencodezen_reasoning_test.go
Normal 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 已丢弃")
|
||||
}
|
||||
@ -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 = ""
|
||||
|
||||
@ -2772,9 +2772,42 @@ 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
|
||||
-- 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
|
||||
|
||||
@ -3216,17 +3249,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
|
||||
@ -3716,7 +3812,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 = {
|
||||
@ -3749,11 +3845,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
|
||||
|
||||
@ -3776,7 +3872,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
|
||||
@ -6487,7 +6584,7 @@ function plugin.refresh_models()
|
||||
end
|
||||
-- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误)
|
||||
if cooled > 0 then
|
||||
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台")
|
||||
logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台", "task")
|
||||
end
|
||||
-- 顺带刷新模型能力元数据(照参考 opencode-capability)。
|
||||
-- ⚠️ 必须在这里(后台定时任务)而不是请求路径 —— 见该节的"非阻塞"约定。
|
||||
@ -6495,19 +6592,27 @@ function plugin.refresh_models()
|
||||
if not cok then
|
||||
-- ⚠️ 失败**必须留痕**:此前这里只有 `pcall` 不管返回值,于是"出站未授权"
|
||||
-- 这类错误被静默吞掉,表现是"能力元数据永远是空",排查时零线索。
|
||||
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 120))
|
||||
logEvent("能力元数据刷新异常: " .. tostring(cres):sub(1, 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("能力元数据未刷新: " .. tostring(cerr):sub(1, 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
|
||||
|
||||
@ -6556,9 +6661,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 .. " 个:" .. table.concat(failed, "; "):sub(1, 300), "warn")
|
||||
end
|
||||
end
|
||||
|
||||
@ -10675,8 +10780,31 @@ 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">
|
||||
<h2 style="margin:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<span>运行日志</span>
|
||||
<span class="grow"></span>
|
||||
<label class="muted" style="font-size:12px;font-weight:400;display:flex;align-items:center;gap:5px">
|
||||
<input type="checkbox" id="ch-log-verbose" onchange="chLogRender()"> 显示例行任务
|
||||
</label>
|
||||
<select id="ch-log-level" onchange="chLogRender()"
|
||||
style="padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);font-size:12px">
|
||||
<option value="all">全部级别</option>
|
||||
<option value="warn">警告及以上</option>
|
||||
<option value="error">仅错误</option>
|
||||
</select>
|
||||
</h2>
|
||||
<div class="muted" style="font-size:12px;margin:6px 0 8px" id="ch-log-hint"></div>
|
||||
<ul id="ch-log" style="font-size:12px;margin:0;padding-left:18px;max-height:320px;overflow:auto"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
@ -10694,15 +10822,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;
|
||||
}
|
||||
@ -10833,7 +11022,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;
|
||||
|
||||
Reference in New Issue
Block a user