diff --git a/internal/core/channels_trae_headers_test.go b/internal/core/channels_trae_headers_test.go new file mode 100644 index 0000000..3c8ca53 --- /dev/null +++ b/internal/core/channels_trae_headers_test.go @@ -0,0 +1,71 @@ +package core + +import "testing" + +// TRAE 的**两套头不能混用** —— 这是 2026-10-20 那次"目录永远拉不到"的真因。 +// +// 事故链:模型目录端点在 `trae-api-cn.mchost.guru`(IDE 面),需要 IDE 头族 +// (Trae UA + X-App-Id + X-Ide-Version* + 凭据里的 X-Machine-Id/X-Device-Id); +// 而 `api.trae.cn` 的签到/积分端点需要另一套(VSCode UA + X-Market-* + +// X-Lscbd-* + 派生 market device id)。插件当时"复用签到头"去拉目录,上游对 +// 22 个 function 全部回 `error_code 2001 Config detail for function: X not found` +// (HTTP 200!),于是远端目录恒空、只能退回兜底表 —— 用户看到的却是 +// "trae 模型不正确 / 渠道不可用",与真因隔了整整一层协议。 +// +// 这个测试把两套头的**契约**钉死:键集合必须互不越界。它不联网,因此能 +// 在 CI 里稳定复现"用错头"这一类回归。 +func TestTraeHeadersKeptSeparate(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) + } + out, err := ps.Call("channels", "__trae_headers_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("probe: %v", err) + } + j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + pick := func(field string) map[string]bool { + set := map[string]bool{} + arr, _ := j[field].([]interface{}) + for _, raw := range arr { + if s, ok := raw.(string); ok { + set[s] = true + } + } + return set + } + ide, chk := pick("ide"), pick("checkin") + if len(ide) == 0 || len(chk) == 0 { + t.Fatalf("两套头都应有内容;实得 ide=%v checkin=%v", ide, chk) + } + // IDE 面(目录/对话):身份与版本头必须在 + for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-App-Version-Code", + "X-Machine-Id", "X-Device-Id", "Request-Traffic-Type"} { + if !ide[k] { + t.Errorf("IDE 头缺少 %q(目录端点会回 2001)", k) + } + } + // IDE 面**不得**带 UG/积分的头 + for _, k := range []string{"X-Market-Client-Id", "X-Lscbd-Aid", "X-Market-User-Id"} { + if ide[k] { + t.Errorf("IDE 头不应包含 %q(那是签到头)", k) + } + } + // 签到头:UG/积分标记必须在 + for _, k := range []string{"X-Market-Client-Id", "X-Market-User-Id", "X-Lscbd-Aid"} { + if !chk[k] { + t.Errorf("签到头缺少 %q", k) + } + } + // 签到头不得冒用 IDE 的 App/机型身份 + for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-Machine-Id"} { + if chk[k] { + t.Errorf("签到头不应包含 %q(那是 IDE 身份)", k) + } + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 2e172ac..2981a43 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -140,7 +140,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", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__trae_catalog_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_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", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__trae_catalog_probe", "__trae_headers_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_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" } @@ -197,7 +197,7 @@ local pickAccountForSource, accountBalanceRaw, balanceTier -- ⇒ 上游回「not able to authenticate you」;而同一账号的**签到领取是成功的** -- (claim_fn 那条路同样拿到 nil,但它内部有 Bearer 兜底且 claim 端点接受)。 -- ⇒ 教训:Lua 的 local 只对**其后的**代码可见;闭包不是延迟绑定的魔法。 -local traeCheckinHeaders, traeFixNickname +local traeCheckinHeaders, traeFixNickname, traeIdeHeaders local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId local PLATFORMS = { @@ -273,6 +273,20 @@ local PLATFORMS = { } }, auth = "none", -- 鉴权头由 headers_fn 负责 parse = "trae_batch", + -- ★★ 只收**适配器真正会发的那条通道**里的模型。 + -- + -- 实测(2026-10-20,真实目录 22 个 function / 93 条去重模型): + -- 同一个 config_name 在不同 function 里的**可用性不同**,而本插件的 + -- trae 适配器只会发 `solo_work_lite`(见 TRAE_FUNCTION)。把别的通道 + -- 的模型也列出来,用户选中后上游要么回**空内容**(kimi-k2.8-preview / + -- deepseek-V3 / glm-4.7 / code-review-judge 实测皆空),要么**悄悄回落到 + -- 默认模型**(custom_model_gpt-5 自称 GPT-4、sagitta 自称 Claude —— + -- 这正是"模型不正确"的真实来源)。 + -- + -- 收窄到 solo_work_lite 后:20 个模型,逐个实测都能返回内容且确实是 + -- 它自称的那个模型(glm-5.3 / kimi-k3 / minimax-m3.1-flash / qwen3.8-max / + -- step-5-preview / deepseek-v4.1-flash …)。 + admit_functions = { "solo_work_lite" }, }, -- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat, -- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。 @@ -301,6 +315,12 @@ local PLATFORMS = { -- custom_model_claude / custom_model_1M 均返回空 content)—— -- 表现就是「模型不正确 / 渠道不可用」。 -- 它们与内部子 agent 同性质:保留在目录里(排查可见),但不进可选列表。 + -- ★ 远端目录一旦可用,就**不再补兜底表**(参考语义:remote ?? fallback)。 + -- 兜底表里的名字是历史快照,其中有若干**上游已不存在**的条目 + -- (实测 sagitta 自称 Claude、aquila 自称 ChatGPT、glm-5 / DeepSeek-V4-Flash + -- 在 solo_work_lite 通道里根本没有配置)—— 补进来 = 给用户一堆"点了回错模型" + -- 的选项。仅在远端目录为空时才用兜底表(那才是它的用途)。 + models_fallback_no_supplement = true, models_hidden = { "browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary", }, @@ -3770,9 +3790,15 @@ local function effectiveModels(provider) end end end - -- 兜底补位(只补远端没有的) - for _, id in ipairs(plat.models or {}) do - if not seen[id] then seen[id] = true out[#out+1] = id end + -- 兜底补位(只补远端没有的)。 + -- ⚠️ plat.models_fallback_no_supplement=true 的平台**不补**:远端目录可用时 + -- 它就是权威,兜底表只是"拉不到时的替代",补进来会把已下架/错映射的名字 + -- 重新塞进列表(见该字段注释里的实测)。 + local haveRemote = type(remote) == "table" and type(remote.ids) == "table" and #remote.ids > 0 + if not (plat.models_fallback_no_supplement and haveRemote) then + for _, id in ipairs(plat.models or {}) do + if not seen[id] then seen[id] = true out[#out+1] = id end + end end -- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。 -- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 —— @@ -4081,6 +4107,18 @@ local function parseRemoteModels(kind, body, provider) if type(g) == "table" then local chan = rstr(g, "function", "Function") -- 准入:未声明 function 或不在白名单 → 整组丢弃 + -- ★ 追加一道「本适配器可发通道」闸门(声明式,见 models_remote.admit_functions): + -- 白名单只说明"这个通道在本适配器的推理端点下可调用",不代表 + -- **适配器会发它** —— trae 适配器恒发 solo_work_lite,别的通道的 + -- 模型即使可调用,经我们这条路发出去也会空回/回落默认模型。 + local admitted = nil + do + local pl = platformById(provider) + local mr2 = pl and pl.models_remote + local af = mr2 and mr2.admit_functions + if type(af) == "table" then admitted = {} for _, f in ipairs(af) do admitted[f] = true end end + end + if admitted and chan and not admitted[chan] then chan = nil end if chan and rankOf[chan] then local list = g.config_info_list or g.ConfigInfoList if type(list) == "table" then @@ -4227,10 +4265,10 @@ local function refreshRemoteModels(provider) 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”这类差异极难查。 + -- TRAE 的鉴权是 Cloud-IDE-JWT + 一组客户端头 —— 不能只发 Bearer。 + -- ⚠️ 必须用 **IDE 头**(traeIdeHeaders),不能用签到头: + -- 签到头是 `api.trae.cn` 的 UG/积分形态(VSCode UA + X-Market-*), + -- 发到 IDE 目录端点会被逐 function 判 2001(见 traeIdeHeaders 的注释)。 local cred for _, a in ipairs(poolList(provider)) do if a.enabled ~= false then @@ -4243,11 +4281,13 @@ local function refreshRemoteModels(provider) end if not cred then lastErr = "无可用 TRAE 凭据" - elseif type(traeCheckinHeaders) ~= "function" then - lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)" + elseif type(traeIdeHeaders) ~= "function" then + lastErr = "traeIdeHeaders 未就绪(前向引用顺序问题)" else - local uid = cred.uid or "" - local h, herr = traeCheckinHeaders(plat, cred, uid) + -- ★ 目录是 **IDE 端点**,必须用 IDE 头(不是签到头)。 + -- 2026-10-20 事故:这里曾复用 traeCheckinHeaders ⇒ 22 个 function 全回 + -- 2001 "Config detail ... not found" ⇒ 永远拉不到目录。详见 traeIdeHeaders。 + local h, herr = traeIdeHeaders(plat, cred, "application/json") if herr then lastErr = tostring(herr) else @@ -9803,6 +9843,52 @@ traeCheckinHeaders = function(plat, cred, uid) return h, nil end +-- traeIdeHeaders(plat, cred, accept):**IDE/Agent 端点**的头(对齐参考 traeSOLOHeaders)。 +-- +-- ⚠️ 与 traeCheckinHeaders 是**两套不同的头**,不能互相替代: +-- - traeCheckinHeaders 面向 `api.trae.cn/trae/api/v2/ug|pay`(签到/积分): +-- VSCode 形态的 UA + X-Market-* + X-Lscbd-* + Vscode-Sessionid, +-- 设备身份用**派生**的 market device id。 +-- - traeIdeHeaders 面向 `trae-api-cn.mchost.guru/api/...`(模型目录、对话): +-- Trae/0.1.52 UA + X-App-Id + X-Ide-Version(*) + Request-Traffic-Type, +-- 设备身份用**凭据里登录时的** device_id / machine_id。 +-- +-- 2026-10-20 真实事故:目录刷新复用了 traeCheckinHeaders,上游对 22 个 function +-- **全部**回 `error_code 2001 Config detail for function: X not found`(HTTP 200), +-- 于是插件永远拿不到远端目录、只剩兜底表 —— 用 IDE 头直发同一端点,返回 +-- 2.6 MB、每个 function 都带 config_info_list。症状("拉不到上游目录")与真因 +-- (头用错了)之间隔着整整一层协议,这就是必须分开写、并在注释里点明的原因。 +traeIdeHeaders = function(plat, cred, accept) + local tok = tostring((cred and cred.access_token) or "") + if tok == "" then return nil, "缺少 access_token" end + local h = { + ["Content-Type"] = "application/json", + ["Accept"] = accept or "application/json", + ["User-Agent"] = (plat and plat.user_agent) or "Trae/0.1.52", + ["Authorization"] = "Cloud-IDE-JWT " .. tok, + ["X-Cloudide-Token"] = tok, + ["X-Ide-Token"] = tok, + ["X-App-Id"] = (plat and plat.app_id) or "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8", + ["X-App-Version"] = "default", + ["X-Ide-Version"] = (plat and plat.ide_version) or "0.1.52", + ["X-Ide-Version-Code"] = (plat and plat.ide_version_code) or "20260811", + ["X-App-Version-Code"] = (plat and plat.ide_version_code) or "20260811", + ["X-Ide-Version-Type"] = "stable", + ["X-Device-Type"] = (plat and plat.device_type) or "macos", + ["X-OS-Version"] = (plat and plat.os_version) or "macOS 15.7.4", + ["X-Device-Brand"] = (plat and plat.device_brand) or "Apple", + ["Request-Traffic-Type"] = "prod", + } + local uid = tostring((cred and cred.uid) or "") + if uid ~= "" then h["X-Uid"] = uid end + -- ★ 设备身份只认凭据里登录时落下的那两个值;不派生、不造。 + local mid = tostring((cred and cred.machine_id) or "") + local did = tostring((cred and cred.device_id) or "") + if mid ~= "" then h["X-Machine-Id"] = mid end + if did ~= "" then h["X-Device-Id"] = did end + return h, nil +end + -- 表驱动签到推广到需自定义头的平台:在 PLATFORMS 定义后绑定。 -- -- credentialRef 是「秘密引用」,creds 是插件自解密后的 JSON(最可靠)。 @@ -13040,6 +13126,33 @@ function plugin.__list_sources_probe() return { json = { sources = out } } end +-- plugin.__trae_headers_probe:只给判据用 —— 返回 trae 两套头各自的键集合。 +-- +-- 存在理由:2026-10-20 事故是「目录刷新用了签到头(traeCheckinHeaders)而不是 +-- IDE 头(traeIdeHeaders)」,上游因此对每个 function 回 2001、目录恒空。 +-- 这个错误**在代码里看不出来**(两套头都"能跑"),只能靠契约钉死: +-- - 签到头面向 api.trae.cn 的 UG/积分:必须有 X-Market-Client-Id / X-Lscbd-*, +-- **不得**有 X-App-Id / X-Machine-Id(那是 IDE 端的身份); +-- - IDE 头面向 trae-api-cn.mchost.guru:必须有 X-App-Id / X-Ide-Version / +-- X-Machine-Id,**不得**有 X-Market-Client-Id。 +-- 判据比对键集合即可,不需要真实网络。 +function plugin.__trae_headers_probe(args) + local plat = platformById("trae") + local cred = { + access_token = "jwt-test", uid = "1000", + device_id = "dev-32", machine_id = "mid-32", + } + local ide = traeIdeHeaders(plat, cred, "application/json") + local chk = traeCheckinHeaders(plat, cred, cred.uid) + local function keyset(h) + local out = {} + for k in pairs(h or {}) do out[#out + 1] = k end + table.sort(out) + return out + end + return { json = { ide = keyset(ide), checkin = keyset(chk) } } +end + -- plugin.__trae_catalog_probe:只给诊断用 —— 原样回传 TRAE 远端模型目录的 -- HTTP 状态与前 1200 字节响应体。 -- @@ -13087,7 +13200,8 @@ function plugin.__trae_catalog_probe(args) h["X-Ide-Version-Code"] = args.ide_version end local resp, err = __llmsproxy_http({ - url = ep.url, method = ep.method or "POST", + url = (type(args.url) == "string" and args.url ~= "" and args.url) or ep.url, + method = ep.method or "POST", headers = h, body = (type(args.body) == "string" and args.body ~= "" and args.body) or ep.body or "{}", }) if err then return { json = { error = tostring(err) } } end