fix(channels): trae 模型表去掉不可调用槽位 + opencode 免费模型按实测收敛

## ① trae:模型选择器里那几个 `custom_model_*` 是**槽位不是模型**
用户报「trae 的模型不正确 / 渠道不可用」。逐个实测(生产):
`custom_model_kimi` / `custom_model_claude` / `custom_model_1M` 返回 **空
content**(`custom_model_gpt-5` 也答非所问),而真实模型
(glm-5.2 → Z.ai GLM、kimi-k3 → Moonshot、minimax-m3 → MiniMax、
Doubao-Seed-2.1-Pro → 字节)全部正常 —— 所以渠道是通的,问题在**表**。

根因:`custom_model_*` 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威
判据是 `is_custom_model: true`。插件自己的 `trae_batch` 解析器早已按这条
**准入**剔除,但**兜底表**把它们当普通模型列了出来;而远端目录在部分账号
下恒空(企业租户账号实测:22 个 function 全回
`error_code 2001 Config detail for function: X not found`),用户只能看到
兜底表 ⇒ 选中槽位后上游回空 ⇒ 「模型坏了」。

修法(**纯插件**,内核零改动):
- 新增 `models_unavailable` 一档:声明「本协议下不可调用」的条目,
  **既不进 UI 选择器,也不进上游源**(与 `models_hidden` 区别:后者只是
  不进 UI,内部子 agent 仍可显式调用);
- trae 的 11 个 `custom_model_*` 移入该档;把 4 个内部子 agent 留在
  `models_hidden`(语义不变);
- 生产验证:trae 源/选择器从 28 → **17**,逐个实测 17 个全部返回非空内容。

## ② 远端目录「空」要能区分原因
`refresh_models` 此前只会报「远端返回空目录」,把两种完全不同的故障混为一谈:
网络/HTTP 故障 vs 服务端 200 但账号侧没有可下发配置。
现在后者如实报:「远端 200 但无模型配置(账号侧未下发 function config,
企业/租户账号需管理员开启,实测 error_code 2001)」——运维据此知道该找
租户管理员,而不是反复重试网络。
另加 `__trae_catalog_probe`(诊断探针):原样回传目录端点的 status/body 前
N 字节/解析条数,并在 2001 现场定位时立了大功(这类"上游 200 却空目录"没有
原始响应只能瞎猜)。

## ③ opencode / localzen:免费模型按**实测**收敛
用户报「localzen 也不能用了」。逐项实测(生产):
- `space-bunny-free` → **410** `Model space-bunny-free has been deprecated.
  Use minimax-m3.1-flash-free instead.`(仍在本地源里 ⇒ 选中必失败)
- `jev-1.13-free` → 400 `Model does not support this protocol.`(非 OpenAI
  chat 协议,本端点永远调不通)
- `beam-free` → 200 但 content 空、无 finish_reason
- 其余 7 个(mimo / nemotron-super-vl / nemotron-lightning / longcat /
  deepseek-v4.1 / glm-5.3-flash / minimax-m3.1-flash)全部正常返回内容

修法:
- `DEAD_GONE_PATTERNS` 补 opencode 的下架文案
  (`Model … has been deprecated`);配阴/阳性判据(参数/字段被弃用**不算**);
- opencode 平台把 jev-1.13-free / beam-free 列入 `models_unavailable`,
  并从兜底表与 models_free 里移除;
- 生产 `localzen` 源(手写源,插件不管)按实测刷新为 7 个可用免费模型,
  逐个验证返回内容。

## 纪律
渠道逻辑全部留在插件里(channels.lua / 插件自装适配器);内核这轮**没有**
新增任何厂商语义 —— 上一轮加进内核的只有通用的 `adapter.hidden` 机制。

(cherry picked from commit 401a7ead27)
This commit is contained in:
root
2026-10-20 09:13:56 +08:00
parent 2924da0032
commit abd0e1d943
3 changed files with 245 additions and 10 deletions

View File

@ -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", "__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", "__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" }
@ -291,14 +291,25 @@ local PLATFORMS = {
"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",
"kimi-k2.6", "minimax-m3", "qwen-3.7-plus", "sagitta", "aquila",
},
-- ★ custom_model_* 是 TRAE 的「自定义模型槽位」,**不是可调用的模型**:
-- 上游目录里它们的判据是 `is_custom_model: true`(本插件的 trae_batch
-- 解析器早就按这条**准入**剔除),但兜底表此前把它们当普通模型列出来 ——
-- 于是远端目录取不到时(企业租户账号实测:22 个 function 全回
-- `2001 Config detail ... not found` ⇒ 目录为空),用户在模型选择器里
-- 能看到这些条目,选中后上游回空内容(实测 custom_model_kimi /
-- custom_model_claude / custom_model_1M 均返回空 content)——
-- 表现就是「模型不正确 / 渠道不可用」。
-- 它们与内部子 agent 同性质:保留在目录里(排查可见),但不进可选列表。
models_hidden = {
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
},
models_unavailable = {
"custom_model_gemini", "custom_model_placeholder", "custom_model_1M_text",
"custom_model_1M", "custom_model_kimi", "custom_model_claude",
"custom_model_gpt-5", "custom_model_no-fc", "custom_model_deepseek_chat",
"custom_model_deepseek_reasoner", "custom_model_deepseek_v4",
},
models_hidden = {
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
},
-- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。
checkin = {
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim",
@ -964,19 +975,27 @@ local PLATFORMS = {
-- * 新增 beam-free / nemotron-3.5-super-vl-free / jev-1.13-free /
-- glm-5.3-flash-free / minimax-m3.1-flash-free
models = {
"big-pickle", "beam-free", "longcat-2.5-preview-free",
"big-pickle", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
"nemotron-3.5-lightning-free", "jev-1.13-free", "glm-5.3-flash-free",
"nemotron-3.5-lightning-free", "glm-5.3-flash-free",
"minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
"deepseek-v4-flash", "deepseek-v4.1-flash",
"glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max",
},
models_free = {
"big-pickle", "beam-free", "longcat-2.5-preview-free",
"big-pickle", "longcat-2.5-preview-free",
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
"nemotron-3.5-lightning-free", "jev-1.13-free",
"nemotron-3.5-lightning-free",
"glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
},
-- ★ 目录里有、但**本协议下不可调用**的免费模型(2026-10-20 逐个实测):
-- * jev-1.13-free → 400 "Model does not support this protocol."
-- (它不是 OpenAI chat 协议,用 /v1/chat/completions 必失败)
-- * beam-free → 200 但 content 为空、无 finish_reason(上游未真正产出)
-- 二者若留在可选列表,用户选中后只会看到"空回复/报错" ⇒ 判为不可用,
-- 既不进 UI 列表,也不进上游源(models_unavailable 与 hidden 的区别见
-- effectiveModels 的注释)。
models_unavailable = { "jev-1.13-free", "beam-free" },
},
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
@ -3735,6 +3754,12 @@ local deadModelIds
local function effectiveModels(provider)
local plat = platformById(provider)
if not plat then return {} end
-- models_unavailable:**声明上就不可调用**的条目(不是"暂不显示")。
-- 与 models_hidden 的区别:hidden 只是不进 UI 列表(内部子 agent 仍可被
-- 显式调用),unavailable 连**上游源**都不进 —— 因为它们调用必然失败/回空,
-- 进源只会让客户端选中后报错(trae 的 custom_model_* 槽位就是这类)。
local unavailable = {}
for _, m in ipairs(plat.models_unavailable or {}) do unavailable[m] = true end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
local out, seen = {}, {}
-- 远端在前(权威顺序)
@ -3755,7 +3780,7 @@ local function effectiveModels(provider)
local dead = deadModelIds(provider)
local kept = {}
for _, id in ipairs(out) do
if not dead[id] then kept[#kept+1] = id end
if not dead[id] and not unavailable[id] then kept[#kept+1] = id end
end
return kept
end
@ -4161,6 +4186,9 @@ end
local function refreshRemoteModels(provider)
local plat = platformById(provider)
if not plat then return nil, "未知平台" end
-- 最近一次目录响应的**原始体**:解析失败时用来区分"网络/HTTP 故障"与
-- "服务端 200 但账号侧没有可下发配置"(后者文案完全不同,见函数尾部)。
local lastBody = nil
local mr = plat.models_remote
if not mr then return nil, "该平台无远端目录端点" end
-- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉)
@ -4256,12 +4284,30 @@ local function refreshRemoteModels(provider)
if err or not resp or resp.status ~= 200 then
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
else
lastBody = resp.body or ""
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
if not seen[id] then seen[id] = true merged[#merged+1] = id end
end
end
end
if #merged == 0 then return nil, lastErr or "远端返回空目录" end
if #merged == 0 then
-- ★ 把"空目录"再细分一层:远程 200 但一条配置都没有,通常不是网络问题,
-- 而是**账号侧**没有可下发的 function 配置(企业租户账号实测:22 个
-- function 全回 error_code 2001 "Config detail for function: X not found")。
-- 这两类原因的处置完全不同(网络重试 vs 找租户管理员),日志不能都说
-- "远端返回空目录"。判据只看响应体里出现 2001 或 function_configs。
if not lastErr then
local seenBody = false
if type(lastBody) == "string" and lastBody:find("function_configs", 1, true) then
seenBody = true
end
if seenBody then
return nil, "远端 200 但无模型配置(账号侧未下发 function config,"
.. "企业/租户账号需管理员开启,实测 error_code 2001)"
end
end
return nil, lastErr or "远端返回空目录"
end
if not plugin.state.remoteModels then plugin.state.remoteModels = {} end
plugin.state.remoteModels[provider] = {
ids = merged,
@ -7184,6 +7230,13 @@ local DEAD_GONE_PATTERNS = {
"unknown%s+model",
"no%s+such%s+model",
"nonexistent%s+model",
-- ★ opencode zen 的下架文案(2026-10-20 实测):
-- 410 `Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.`
-- 此前不识别 ⇒ 该模型一直留在源里,用户选中必失败("渠道不可用"观感)。
-- 带 "has been deprecated" 整句是为了不误伤「参数 X 已弃用」这类**非模型**文案:
-- "Model ... has been deprecated" 的主语必须是 Model(%s+ 吃掉名字)。
"model%s+%S+%s+has%s+been%s+deprecated",
"model%s+has%s+been%s+deprecated",
}
-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。
@ -8089,6 +8142,7 @@ function plugin.model_list(req)
-- models_hidden:内部子 agent(照参考 isHidden 语义)不进用户可选列表。
local hidden = {}
for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end
for _, m in ipairs(plat.models_unavailable or {}) do hidden[m] = true end
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
local free = {}
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
@ -12986,6 +13040,66 @@ function plugin.__list_sources_probe()
return { json = { sources = out } }
end
-- plugin.__trae_catalog_probe:只给诊断用 —— 原样回传 TRAE 远端模型目录的
-- HTTP 状态与前 1200 字节响应体。
--
-- 存在理由:`refresh_models` 只把「解析出几条」汇总成「远端返回空目录」,
-- 看不到**上游到底回了什么**(空数组?错误信封?形状变了?)。模型目录决定
-- 用户在渠道页能选哪些模型,它是"渠道不可用"类报障的第一现场,不能只有一句
-- 二手结论。探测逻辑与 refreshRemoteModels 的 trae 分支**同源**(同一个
-- traeCheckinHeaders + 同一份请求体),避免"探针通、真路径不通"。
function plugin.__trae_catalog_probe(args)
args = args or {}
local plat = platformById("trae")
if not plat or not plat.models_remote then
return { json = { error = "trae 平台未声明 models_remote" } }
end
local ep = (plat.models_remote.endpoints or {})[1] or {}
local cred
for _, a in ipairs(poolList("trae")) 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 return { json = { error = "无可用 TRAE 凭据" } } end
if type(traeCheckinHeaders) ~= "function" then
return { json = { error = "traeCheckinHeaders 未就绪" } }
end
local h, herr = traeCheckinHeaders(plat, cred, cred.uid or "")
if herr then return { json = { error = "构造头部失败: " .. tostring(herr) } } end
-- 诊断用覆盖:Accept 在参考里对目录请求是 application/json,而 checkin 的
-- 头部构造给的是 text/event-stream —— 用它可以判定"是不是 Accept 导致空目录"。
if type(args.accept) == "string" and args.accept ~= "" then
h["Accept"] = args.accept
end
-- 诊断用:额外头部(企业/租户类账号的 IDE 端点通常要求租户身份,
-- 而参考的 traeSOLOHeaders 不发。用它验证"缺租户头导致全 2001")。
if type(args.headers) == "table" then
for k, v in pairs(args.headers) do
if type(k) == "string" and type(v) == "string" then h[k] = v end
end
end
if type(args.ide_version) == "string" and args.ide_version ~= "" then
h["X-Ide-Version"] = args.ide_version
h["X-App-Version-Code"] = args.ide_version
h["X-Ide-Version-Code"] = args.ide_version
end
local resp, err = __llmsproxy_http({
url = 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
local body = (resp and resp.body) or ""
return { json = {
status = resp and resp.status or 0,
bytes = #body,
head = body:sub(1, math.min(tonumber(args.clip) or 1200, #body)),
parsed = #parseRemoteModels("trae_batch", body, "trae"),
} }
end
-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。
-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题
-- 没有它只能靠猜。