From 423dd5cf22b3a411f36aec5c957917e3507169da Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 08:28:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(trae):=20batch=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=EF=BC=88=E5=90=AB=E5=8F=82=E8=80=83=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E4=B8=A4=E4=B8=AA=E7=9C=9F=E5=AE=9E=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=E4=BF=AE=E5=A4=8D=20+=20=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我们此前 trae 的模型列表是**纯硬编码**(32 项名字),既没有真实元数据,也没打 batch 端点。参考的 parseTraeBatchModelList 记录了**两个已修的缺陷**,移植时 必须一起带上,否则等于把 bug 再引进一遍: ## ① 通道白名单是「准入」而不只是「排序」(issue IKJOZ7) 22 个 function 里有一半在本插件推理端点下**不可调用**(实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。只把白名单用于"同模型择优" 的旧写法 = "目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,而错误文案 指向**模型**,极易被误判成"这个模型坏了"。现在不在表内的通道**整组丢弃**。 ## ② 空档位不得覆盖有档位(issue IKI7WT/IKILR7) 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据**相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在**最后**,实测 13 个 模型因此丢掉 reasoning_effort_config、全部显示"未提供推理等级"。 合并规则(与参考逐条对齐):空档位不得覆盖有档位;两侧都有档位时按通道优先级 取更靠前者;其余保持"后覆盖前"。 ## ③ is_custom_model 是「仅可见但不可调用」的权威判据 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。 ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志随服务端下发变化, 曾据名字把 qwen3.8-flash 误记为应剔除(它其实正常可用)。 ## 附带:远端目录**元数据**不再丢 原来目录只回 id ⇒ 参考能提供的上下文窗口/输出上限/思考档位/消耗倍率全丢。 现按参考存下完整条目并在 model_list 以 `remote` 字段报出。 ⚠ contextWindow 取 `context_window_tokens.dev` 而非 `max`:max 需开启 display_config.max_mode 才可用(我们不实现该开关),采信 max 会让上层以为有 1M 窗口、实际被上游拒。 ## 基础设施 - refreshRemoteModels 支持 ep.body(POST 型目录端点)与 ep.sign(复用平台既有 的头部构造,不另写一套 —— 否则"chat 能通、目录 401"极难查)。 - trae 走 sign="trae_solo" 复用 traeCheckinHeaders(Cloud-IDE-JWT + 约 20 个 客户端头;参考实测简化头会被拒)。 - 新增 __trae_entry_probe(诊断"合并规则选中了哪条"),此前只能靠猜。 ## 判据(3 条 + 变异 3/3) internal/core/channels_trae_catalog_test.go - 白名单准入 + 档位来源(空档位不得覆盖有档位) - 四条硬性过滤(usage/config_switch/is_invisible_to_user/is_custom_model) 且 **is_custom_model=false 的 custom_model_* 条目必须保留**(判据是标志值) - 请求体必须传全部 22 个 function(与真实 IDE 一致;只传聊天通道会让非聊天 通道条目位置错乱甚至被跳过) 变异:白名单不做准入 → 报红;无条件后覆盖前 → 报红;不过滤 custom → 报红。 ★ 调试中发现并修掉我自己写的一个**静默失效**: `declaresEffort` 读 `e.reasoning_effort_config` 而 `parseEntry` 存的是 `e.reasoning` ⇒ 判定恒为 false ⇒ 规则①永不生效(正是它要防的缺陷)。 判据当场抓到。**教训:跨函数用同一份数据时,字段名必须对齐 —— 这类不一致 不会报错,只会让规则静默失效。** 验证:internal/core 369s 全绿。 --- internal/core/channels_trae_catalog_test.go | 230 +++++++++++++ internal/lua/plugins/channels.lua | 343 +++++++++++++++++++- 2 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 internal/core/channels_trae_catalog_test.go diff --git a/internal/core/channels_trae_catalog_test.go b/internal/core/channels_trae_catalog_test.go new file mode 100644 index 0000000..4644c6b --- /dev/null +++ b/internal/core/channels_trae_catalog_test.go @@ -0,0 +1,230 @@ +package core + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "llmsproxy/internal/lua" +) + +// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上 +// (参考 parseTraeBatchModelList 的注释记录了它们)。 +// +// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7): +// +// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023 +// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于 +// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败, +// 且错误指向**模型**,极易被误判成"这个模型坏了"。 +// +// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7): +// +// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** —— +// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉 +// reasoning_effort_config,全部显示"未提供推理等级"。 +func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) { + var mu sync.Mutex + var gotBody string + h := newTraeCatalogHarness(t, func(body string) { + mu.Lock() + gotBody = body + mu.Unlock() + }) + + ids := catalogIDs(t, h, "trae") + joined := strings.Join(ids, ",") + + // ① 白名单准入:不可调用通道里的模型必须**被丢弃** + if strings.Contains(joined, "only-in-bad-channel") { + t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+ + "否则用户选中它必然失败且错误指向模型:%v", ids) + } + // 但通用通道里的模型要在 + if !strings.Contains(joined, "glm-5.2") { + t.Fatalf("可调用通道里的模型不该被丢:%v", ids) + } + + // ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与 + // solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。 + // 判据看**报出的档位**,不是"哪条被选中"这类内部信号。 + if !h.modelHasReasoningOptions("glm-5.2") { + t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+ + "solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+ + "「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids) + } + + // ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)—— + // 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。 + mu.Lock() + b := gotBody + mu.Unlock() + if b == "" { + t.Fatal("未捕获到目录请求体") + } + for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} { + if !strings.Contains(b, want) { + t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b) + } + } +} + +// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。 +func TestTraeBatchModelCatalogHardFilters(t *testing.T) { + h := newTraeCatalogHarness(t, nil) + ids := catalogIDs(t, h, "trae") + joined := strings.Join(ids, ",") + for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} { + if strings.Contains(joined, bad) { + t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids) + } + } + // ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false + // 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。 + if !strings.Contains(joined, "custom_model_prefix_but_ok") { + t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids) + } + // ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里, + // 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。 + // 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。 + if !strings.Contains(joined, "no-function-model") { + t.Errorf("合法组内的条目不该被丢:%v", ids) + } + t.Logf("✓ 过滤后目录: %v", ids) +} + +// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ── + +type traeCatalogHarness struct { + t *testing.T + ps *lua.Plugins +} + +func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if onBody != nil { + onBody(string(b)) + } + w.Header().Set("Content-Type", "application/json") + // 三个通道: + // solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位 + // solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位** + // inline_chat(**白名单外**)—— 只列出 only-in-bad-channel + fmt.Fprint(w, `{"function_configs":[`+ + // ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目 + `{"function":"solo_agent","config_info_list":[`+ + `{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+ + `"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+ + `{"config_name":"summary-model","usage":"summary","config_switch":true},`+ + `{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+ + `{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+ + `{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+ + // is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用") + `{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+ + `"display_config":{"is_custom_model":true}},`+ + // 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留** + // (参考点名:判据是标志的值,不是模型名) + `{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+ + `"display_config":{"is_custom_model":false}}`+ + `]},`+ + // ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置 + `{"function":"solo_work_lite","config_info_list":[`+ + `{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+ + `]},`+ + // ③ 白名单外通道:整组必须被丢弃 + `{"function":"inline_chat","config_info_list":[`+ + `{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+ + `]}`+ + `]}`) + })) + t.Cleanup(ts.Close) + + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + h := &traeCatalogHarness{t: t, ps: ps} + // 把目录端点指到假上游 + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "trae", + "models_remote": map[string]interface{}{"endpoint0": ts.URL}, + }); err != nil { + t.Fatal(err) + } + // 塞一个 TRAE 账号(目录请求需要凭据) + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "trae", + "account_id": "cat-trae", + "credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`, + }); err != nil { + t.Fatal(err) + } + return h +} + +func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string { + t.Helper() + if _, err := h.ps.Call("channels", "model_refresh", + map[string]interface{}{"platform": platform}); err != nil { + t.Fatalf("model_refresh: %v", err) + } + res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform}) + if err != nil { + t.Fatal(err) + } + j := jsonOf(t, res) + var out []string + arr, _ := j["models"].([]interface{}) + for _, e := range arr { + if m, ok := e.(map[string]interface{}); ok { + if id, ok := m["id"].(string); ok { + out = append(out, id) + } + } + } + return out +} + +// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。 +func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool { + h.t.Helper() + res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"}) + if err != nil { + h.t.Fatal(err) + } + j := jsonOf(h.t, res) + arr, _ := j["models"].([]interface{}) + for _, e := range arr { + m, ok := e.(map[string]interface{}) + if !ok { + continue + } + if m["id"] != modelID { + continue + } + // 档位的真实出口:model_list 把远端目录解出的档位放在 + // `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。 + for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} { + if v, ok := m[k].([]interface{}); ok && len(v) > 0 { + return true + } + } + if rm, ok := m["remote"].(map[string]interface{}); ok { + if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 { + return true + } + } + } + return false +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 629011d..2dcb834 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -128,6 +128,41 @@ local PLATFORMS = { }, }, hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" }, + -- ★ 远端模型目录:POST /api/ide/v1/batch_get_detail_param(通过 models_remote)。 + -- + -- 为什么要有它:**只有这个端点能告诉我们每个模型的真实元数据** —— + -- 上下文窗口、输出上限、是否支持图像/工具响应图像、以及**思考档位**。 + -- 硬编码的 models 兜底表只有名字,于是 UI 上所有模型都显示"未提供推理等级", + -- 且多模态能力无从得知。 + -- + -- ⚠ 端点选择:**必须用 batch**(多通道)而不是单通道 get_detail_param。 + -- 真实 CN IDE 用 batch 一次查 22 个 function,每个 function 各自一套目录; + -- 只传聊天通道会让非聊天通道的条目在响应里**位置错乱甚至被跳过**(参考实测)。 + -- + -- ⚠ 请求体必须传**全部 22 个 function**(与真实 IDE 一致),不是只传我们关心的 + -- 那几个 —— 理由同上:响应分组与请求顺序相关。解析侧再用白名单做**准入**。 + models_remote = { + endpoints = { { + url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param", + method = "POST", + body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",' + .. '"builder_v3","builder","chat","inline_chat","git_ai",' + .. '"custom_agent_generation","utils","code_reviewer","code_review_summary",' + .. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",' + .. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",' + .. '"system_diagnosis"],"agent_type":"",' + .. '"current_config_info":{"config_name":"","is_custom_model":false},' + .. '"mode_type":0,"access_type":0,"ab_force_vids":"",' + .. '"ab_autotest_advanced_mode":0,"show_custom_model":true}', + -- ★ 鉴权方案是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的 + -- 设备身份),**不能只发 Bearer** —— 参考注释点明简化头实测失败。 + -- 走 sign="trae_solo"(复用平台既有的 traeCheckinHeaders 实现, + -- 与 codearts 的 ep.sign 同一模式,不另写一套头部构造)。 + sign = "trae_solo", + } }, + auth = "none", -- 鉴权头由 headers_fn 负责 + parse = "trae_batch", + }, -- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat, -- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。 base_url = "https://trae-api-cn.mchost.guru", @@ -3139,6 +3174,219 @@ local function parseRemoteModels(kind, body, provider) end end + elseif kind == "trae_batch" then + -- ★ TRAE 的 batch_get_detail_param(多通道模型目录)。 + -- + -- 参考实现(parseTraeBatchModelList)记录了**三个真实缺陷**,移植时必须一起 + -- 带上,否则等于把已修好的 bug 再引进一遍: + -- + -- ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7): + -- 22 个 function 里有一半在本插件推理端点下**不可调用** + -- (实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。 + -- 旧实现只把白名单用于"同模型择优",于是"目录声明了什么就发什么" —— + -- 用户选中那些模型必然失败,而错误文案指向**模型**,极易被误判成 + -- "这个模型坏了"。现在不在表内的通道**整组丢弃**。 + -- + -- ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7): + -- 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据 + -- **相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在最后, + -- 实测 13 个模型因此丢掉 reasoning_effort_config,全部显示"未提供推理等级"。 + -- + -- ③ **is_custom_model 是「仅可见但不可调用」的权威判据**: + -- 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。 + -- ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志会随服务端下发变化 + -- (某次快照里 5 个条目全部失效、全目录 true 的条目数为 0)。 + -- + -- 另有硬性过滤:usage 必须 chat_completion、config_switch 不得 false、 + -- is_invisible_to_user 不得 true。 + local TRAE_CHANNELS = { + "solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote", + "solo_agent_lite", + "chat_v3", "builder_v3", "solo_coder", "solo_design_lite", + "solo_design_remote", "git_ai", "code_reviewer", "code_review_summary", + "multimodal", "system_diagnosis", + } + local rankOf = {} + for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end + + -- 取布尔字段(两种大小写) + local function rbool(t, a, b) + local v = t[a] + if v == nil and b then v = t[b] end + return v + end + local function rstr(t, a, b) + local v = t[a] + if v == nil and b then v = t[b] end + return type(v) == "string" and v or nil + end + local function rnum(t, a, b) + local v = t[a] + if v == nil and b then v = t[b] end + return tonumber(v) + end + + -- declaresEffort(entry):该条目**能否真正声明出**思考档位。 + -- ⚠ 必须与适配器的档位判定完全一致(配置存在 + 未显式 support_thinking:false + -- + options 非空)。只判"配置存在"不够:{support_thinking:false, options:[]} + -- 与 {...options:['high']} 都"存在配置",但前者在 UI 上仍显示"未提供推理等级" + -- —— 按"存在即优先"合并等于没修。 + local function declaresEffort(e) + -- ⚠ 字段名必须与 parseEntry 存的一致(e.reasoning)。写成 e.reasoning_effort_config + -- 会让整个判定**恒为 false** ⇒ 规则①永不生效 ⇒ 空档位照样覆盖有档位 + -- (正是该规则要防的缺陷;判据当场抓到)。 + local rc = e.reasoning + if type(rc) ~= "table" then return false end + local st = rbool(rc, "support_thinking", "supportThinking") + if st == false then return false end + local opts = rc.options or rc.Options + return type(opts) == "table" and #opts > 0 + end + + -- parseEntry(it, channel):把一条 config_info_list 条目解成我们关心的字段。 + local function parseEntry(it, chan) + local id = rstr(it, "config_name", "ConfigName") + if not id or id == "" then return nil end + local disp = it.display_config or it.DisplayConfig + if type(disp) ~= "table" then disp = {} end + local e = { + id = id, + name = rstr(disp, "display_name", "displayName") or id, + chan = chan, + usage = rstr(it, "usage", "Usage"), + enabled = rbool(it, "config_switch", "ConfigSwitch"), + hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"), + custom = rbool(disp, "is_custom_model", "IsCustomModel"), + reasoning = it.reasoning_effort_config or it.reasoningEffortConfig, + } + -- ⚠ contextWindow 取 `dev` 而非 `max`:真实条目形如 {dev:200000, max:1000000}, + -- 而 max 需开启 display_config.max_mode 才可用(我们不实现该开关)。 + -- 采信 max 会让上层以为有 1M 窗口、实际请求被上游拒。 + local cw = it.context_window_tokens or it.contextWindowTokens + if type(cw) == "table" then + e.contextWindow = rnum(cw, "dev", "Dev") + end + -- 输出上限:model_detail_list[].max_tokens(取首条的 max_tokens) + local mdl = it.model_detail_list or it.modelDetailList + if type(mdl) == "table" and type(mdl[1]) == "table" then + e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens") + end + -- 计费:display_contact_config 是 **JSON 字符串**(需二次解析) + local dcc = it.display_contact_config or it.DisplayContactConfig + if type(dcc) == "string" and dcc ~= "" then + local okj, cfg = pcall(json.decode, dcc) + if okj and type(cfg) == "table" then + local rate = cfg.consumption_rate or cfg.ConsumptionRate + if type(rate) == "table" then + local enable = rbool(rate, "enable", "Enable") + if enable ~= false then + local data = rate.data or rate.Data + if type(data) == "table" then + local v = rnum(data, "rate", "Rate") + if v and v >= 0 then e.creditsRate = v end + end + end + end + -- 活动折扣:**不能只看 enable** —— 实测 enable=true 但当前无折扣的形态 + -- 形如 {type:"none", before:0.13, after:0.13, discount:100} + -- (discount=100 表示无折扣,before==after)。照显会让用户以为有活动。 + local disc = cfg.activity_discount or cfg.ActivityDiscount + if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then + local ddata = disc.data or disc.Data + if type(ddata) == "table" then + local cur = ddata.current or ddata.Current + if type(cur) == "table" then + local dtype = (rstr(cur, "discount_type", "discountType") or "") + dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "") + if dtype ~= "" and dtype ~= "none" then + local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate") + local after = rnum(cur, "consumption_rate", "consumptionRate") + -- 必须是真的降价:before 有值、为正、且严格大于 after + if before and before > 0 and (not after or before > after) then + -- 截止时间:data 下任意带 end_at 的子对象 + local endsAt + for _, v in pairs(ddata) do + if type(v) == "table" then + local endv = rnum(v, "end_at", "endAt") + if endv and endv > 0 then endsAt = endv break end + end + end + local nowS = os.time and os.time() or 0 + if not endsAt or endsAt > nowS then + e.origCreditsRate = before + e.discountEndsAt = endsAt + end + end + end + end + end + end + end + end + return e + end + + local groups = rec.function_configs or rec.functionConfigs + if type(groups) == "table" then + local byId, chosenRank, order = {}, {}, {} + for _, g in ipairs(groups) do + if type(g) == "table" then + local chan = rstr(g, "function", "Function") + -- 准入:未声明 function 或不在白名单 → 整组丢弃 + if chan and rankOf[chan] then + local list = g.config_info_list or g.ConfigInfoList + if type(list) == "table" then + for _, it in ipairs(list) do + if type(it) == "table" then + local e = parseEntry(it, chan) + if e then + -- 硬性过滤(不设开关) + local ok = true + if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end + if e.enabled == false then ok = false end + if e.hidden == true then ok = false end + -- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝) + if e.custom == true then ok = false end + if ok then + local inc = byId[e.id] + if inc == nil then + byId[e.id] = e + chosenRank[e.id] = rankOf[chan] + order[#order + 1] = e.id + else + local incEff = declaresEffort(inc) + local candEff = declaresEffort(e) + local take = true + if incEff and not candEff then + take = false -- 规则①:空档位不得覆盖有档位 + elseif incEff and candEff then + -- 规则②:两侧都有档位 → 取通道优先级更靠前的 + take = rankOf[chan] < (chosenRank[e.id] or 0) + end + if take then + byId[e.id] = e + chosenRank[e.id] = rankOf[chan] + end + end + end + end + end + end + end + end + end + end + for _, id in ipairs(order) do + out[#out + 1] = id + -- 元数据另存(model_list 用它报出上下文窗口/档位/倍率): + -- ⚠ 只报 id 会把参考能提供的元数据全部丢掉(档位信息在解析阶段就没了, + -- 上层只能显示"未提供推理等级")。 + if not plugin.state.modelMeta then plugin.state.modelMeta = {} end + plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {} + plugin.state.modelMeta[provider][id] = byId[id] + end + end + elseif kind == "zcode_configs" then -- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象** -- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。 @@ -3208,6 +3456,10 @@ local function refreshRemoteModels(provider) local url = ep.url if ep.query then url = url .. "?" .. ep.query end local headers = { ["Accept"] = "application/json" } + -- ★ ep.body:POST 型目录端点(如 trae 的 batch_get_detail_param 必须 POST + + -- 指定请求体)。缺失时按 GET。 + local method = ep.method or "GET" + if ep.body then headers["Content-Type"] = "application/json" end local useAuth = ep.url_auth ~= false and mr.auth ~= "none" if useAuth and tok and tok ~= "" then headers["Authorization"] = "Bearer " .. tok @@ -3221,7 +3473,38 @@ local function refreshRemoteModels(provider) -- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求 -- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头, -- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。 - local req = { url = url, method = ep.method or "GET", headers = headers } + local req = { url = url, method = method, headers = headers } + if ep.body then req.body = ep.body end + if ep.sign == "trae_solo" then + -- TRAE 的鉴权是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的 + -- 设备身份)—— 不能只发 Bearer。这里复用**平台既有的** traeCheckinHeaders + -- 实现(它已经过实测:简化头会被拒),不另写一套头部构造, + -- 否则“chat 能通、目录 401”这类差异极难查。 + local cred + for _, a in ipairs(poolList(provider)) do + if a.enabled ~= false then + local raw = __llmsproxy_secrets.get(a.credentialRef) + local okj, cj = pcall(json.decode, raw or "") + if okj and type(cj) == "table" and cj.access_token then + cred = cj break + end + end + end + if not cred then + lastErr = "无可用 TRAE 凭据" + elseif type(traeCheckinHeaders) ~= "function" then + lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)" + else + local uid = cred.uid or "" + local h, herr = traeCheckinHeaders(plat, cred, uid) + if herr then + lastErr = tostring(herr) + else + -- 融平台头(后者覆盖前面的 Accept,但保留 Authorization 等) + for k, v in pairs(h or {}) do req.headers[k] = v end + end + end + end if ep.sign == "codearts" then local cred for _, a in ipairs(poolList(provider)) do @@ -4874,6 +5157,7 @@ end -- 改成 local 后这个隐式依赖就断了 —— 表现为 -- attempt to call global 'traeCallback' (a nil value)。 local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId + local traeCheckinHeaders, traeFixNickname function plugin.poll_login(req) @@ -6828,6 +7112,40 @@ function plugin.model_list(req) toolCall = cap.toolCall, } end + -- ★ 远端目录解析出的元数据(如 trae 的上下文窗口/输出上限/思考档位/倍率)。 + -- 之前目录只报 id ⇒ 参考能提供的这些字段全丢,UI 只能显示"未提供推理等级"。 + -- 能力表(models.dev)没给的值用远端目录补(远端是**权威**:它就是这个 + -- 通道实际可用的配置)。两者都有时不覆盖能力表(后者是跨平台归一后的口径)。 + local rm = plugin.state.modelMeta and plugin.state.modelMeta[plat.id] + and plugin.state.modelMeta[plat.id][m] + if rm then + entry.remote = {} + if rm.contextWindow then + entry.remote.contextWindow = rm.contextWindow + if not (cap and cap.contextWindow) then entry.contextWindow = rm.contextWindow end + end + if rm.maxOutputTokens then + entry.remote.maxOutputTokens = rm.maxOutputTokens + if not (cap and cap.maxOutputTokens) then entry.maxOutputTokens = rm.maxOutputTokens end + end + -- ⚠ `function` 是 Lua 关键字,必须用中括号键(本文件第 3 次踩)。 + if rm.chan then entry.remote["function"] = rm.chan end + if rm.creditsRate then entry.remote.creditsRate = rm.creditsRate end + if rm.origCreditsRate then entry.remote.origCreditsRate = rm.origCreditsRate end + if rm.discountEndsAt then entry.remote.discountEndsAt = rm.discountEndsAt end + -- 思考档位:从远端条目解出(列表空/显式 support_thinking:false 则视为无) + local rc = rm.reasoning + if type(rc) == "table" then + local st = rc.support_thinking + if st == nil then st = rc.supportThinking end + local opts = rc.options or rc.Options + if st ~= false and type(opts) == "table" and #opts > 0 then + entry.remote.efforts = opts + if not (cap and cap.efforts) then entry.efforts = opts end + entry.reasoning = true + end + end + end out[#out+1] = entry end end @@ -10828,6 +11146,27 @@ function plugin.claims_capable(args) } } end +-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。 +-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题 +-- 没有它只能靠猜。 +function plugin.__trae_entry_probe(args) + args = args or {} + local id = tostring(args.model or "") + local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae + local e = meta and meta[id] + if not e then return { json = { error = "无该模型元数据" } } end + local rc = e.reasoning + local opts = type(rc) == "table" and (rc.options or rc.Options) or nil + return { json = { + id = id, chan = e.chan, usage = e.usage, + enabled = e.enabled, hidden = e.hidden, custom = e.custom, + has_reasoning = rc ~= nil, + n_options = type(opts) == "table" and #opts or 0, + support_thinking = type(rc) == "table" + and (rc.support_thinking or rc.supportThinking) or nil, + } } +end + -- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。 -- -- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在