From a5923b37971e666ea347581351aa60a395669658 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 20:47:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(buddy):=20=E6=96=B0=E5=A2=9E=E5=86=85?= =?UTF-8?q?=E7=BD=AE=20buddy=20=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=88glm-5.1?= =?UTF-8?q?=20=E7=9A=84=20reasoning=5Feffort=20+=20=E6=B5=81=E5=BC=8F=20Ac?= =?UTF-8?q?cept=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 真实缺陷 ①:glm-5.1 恒 400(用户报"这个模型不可用") 上游回 `400 {"code":11150,"msg":"the reasoning effort value is not supported by the current model"}`。探针逐字对照实验(body 完全由探针给定): glm-5.1 裸(无该字段) → 400 11150 ✗ glm-5.1 + reasoning_effort:medium → 200 ✓ glm-5.1 空串 → 400 11150 ✗ **任何非空档位都被接受**(low/medium/high/xhigh/max/minimal 逐个 200); 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)⇒ 这是 glm-5.1 **独有**要求。 参考 product.ts:237 声明它只有 `['medium']` 一档。 ## 真实缺陷 ②:流式请求缺 Accept: text/event-stream → 空回复 不发该头时上游**接受请求(200)但只回一帧空内容**(completion_tokens=0、 finish_reason 直接 stop),既不报错也不产出正文 —— 表现是"模型不回答", 极易误判成模型坏了或额度不足。同 body 加该头 → 39~43 帧正常 SSE。 参考 buddy-adapter.ts 的 send() 固定发它。 ## 实现:内置 `buddy.lua`(不是插件自装) **为什么不用插件自装适配器**(channels-zcode/qoder/cline 那条路): `channels.lua` 的**主 chunk 已 248 个 local**,Lua/LuaJIT 上限是 200 —— 超出的 local 会**静默退化为全局**(`luac -p` 通过,运行时才 `attempt to call global 'x' (a nil value)`)。实测证据:`adaptersDir` 是第 208 个 ⇒ 调用点拿到 nil ⇒ `loadAnthropic` 静默返回 nil ⇒ **zcode 适配器从来没有基座** ("复用 anthropic"的意图从未生效,只是它自写了全部转换所以能用)。 我第一版就是这么写的,结果适配器装不上(`adapter channels-buddy not loaded`), 且 `hy3`/`glm-5.3`/`kimi-k3-1` 全被打成空回复 —— 只有 `glm-5.2` 因多源兜底 "看起来正常",症状再次指向错误方向。 ⇒ 改走内置适配器(与 trae/sensenova/opencodezen 同性质,`deploy.sh` 自动同步), `channels.lua` 只改**一行** `adapter = "buddy"`,不再触碰那个已贴上限的文件。 ## ★ 排查中发现的沙箱事实(值得记) **适配器运行在另一个沙箱 VM**:那里 `__llmsproxy_plugin_dir` 与 `LLMSPROXY_PLUGIN_DIR` **都不存在**(实测均为 nil),**也没有文件系统访问**。 故任何"在适配器里读兄弟适配器源码"的写法都拿不到基座且静默降级。 判据 `TestBuddyAdapterDefinesAllTransforms` 咬住这条。 ## 顺带修:hy4-preview-x 从目录剔除 实测它对上游**恒回 11102**(`model service info not found`,加/不加 reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。它被服务端 agents 引用,于是 `agentRef` 例外把它放回目录 ⇒ 用户选中必 400 而错误指向模型。 参考用**白名单重建**达到同样效果(其兜底表里没有它)。本仓加等价的精确黑名单 (只放实测确认的,不做猜测),并在两处兜底目录里一并剔除。 ## 验证 buddy **15/15 模型全部可用**(含 glm-5.1),逐一实测无失败; `hy4-preview-x` 已从源目录消失(16 → 15)。 ## 判据(3 条 + 变异 2/2) - `TestBuddyAdapterEffortAndHeaders`:glm-5.1 补 medium / hy3 不补 / 调用方给的档位被尊重 / Accept 必须是 SSE + 身份头齐备 - `TestBuddyAdapterKeepsOpenAIWire`:协议转换与 openai 一致(只是补两个字段) - `TestBuddyAdapterDefinesAllTransforms`:**四个转换函数都存在** —— 咬"静默降级"而非"某模型能回话"(后者会被多源兜底掩盖,本会话已栽两次) - 变异:删 stream_chunk / 不补 effort → 均报红 ## 一起提交的其它独立缺陷(均在本次调查中发现) - buddy 目录过滤(四类判据)与 hy4-preview-x 剔除 --- .../channels_buddy_catalog_filter_test.go | 28 +++ internal/lua/adapter_buddy_test.go | 116 ++++++++++ internal/lua/adapters/buddy.lua | 201 ++++++++++++++++++ internal/lua/plugins/channels.lua | 31 ++- 4 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 internal/lua/adapter_buddy_test.go create mode 100644 internal/lua/adapters/buddy.lua diff --git a/internal/core/channels_buddy_catalog_filter_test.go b/internal/core/channels_buddy_catalog_filter_test.go index b8bc5c7..62da248 100644 --- a/internal/core/channels_buddy_catalog_filter_test.go +++ b/internal/core/channels_buddy_catalog_filter_test.go @@ -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 例外也过闸") +} diff --git a/internal/lua/adapter_buddy_test.go b/internal/lua/adapter_buddy_test.go new file mode 100644 index 0000000..348fdcc --- /dev/null +++ b/internal/lua/adapter_buddy_test.go @@ -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("✓ 四个转换函数齐备") +} diff --git a/internal/lua/adapters/buddy.lua b/internal/lua/adapters/buddy.lua new file mode 100644 index 0000000..58b4e51 --- /dev/null +++ b/internal/lua/adapters/buddy.lua @@ -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 diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 4c005ef..7a01302 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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,不能用 @@ -3317,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 @@ -3349,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