Compare commits

...

5 Commits

Author SHA1 Message Date
771b95a133 fix(channels): admit_functions 落到 effectiveModels(解析器不承担产品策略)
上一提交把「本适配器会发的通道」过滤写进了 trae 的目录解析器,导致两个解析器判据
回归:同模型在 solo_agent(有思考档位)与 solo_work_lite(无档位)同时出现时,
整组 solo_agent 被丢弃 ⇒ 档位信息丢光,模型显示"未提供推理等级"。

正确分层:
- **解析器**只负责把目录如实解出来(含跨通道元数据合并、白名单准入);
- **产品策略**(只暴露适配器能发的通道)放到 effectiveModels,作用在对外
  暴露的 id 列表上。

实现要点:合并会选"更优"的那条(往往不在我们要发的通道里),所以判据不能看
合并后条目的 chan —— 解析时额外记 `chans`(该 id 在哪些通道出现过),策略按
chans 求交集。夹具同步调整:硬性过滤的条目放进 solo_work_lite(生产里适配器
恒发这条通道),inline_chat 仍用于白名单判据。
2026-10-20 10:12:51 +08:00
441de434f5 chore(gui): 版本号 2.0.45(与内核同版本) 2026-10-20 09:54:06 +08:00
9feef8f4cb fix(channels): 渠道卡片 logged_in 改成看账号池(此前所有池接入的平台都显示未登录)
## 真因:logged_in 判的是旧单账号键
用户报「codearts 还是不可用」。查下来渠道**本身是通的**(9 个模型逐个实测都返回
内容),不可用的是**UI 的登录态判断**:

`list_platforms` 写的是 `credentialOf("channels:" .. platform)` —— 那是**旧版
单账号**的保险库键。现网凭据键是 `channels:<platform>:<accountId>`(一平台多
账号),于是**所有**通过账号池接入的平台都被判成"未登录":

    修复前(生产):trae=False minimax=False codearts=False qoder=False zcode=False
                    (只有 buddy=True —— 它还留着一条旧格式凭据)
    修复后:          trae=True  minimax=True  codearts=True qoder=True  zcode=True

UI 据此把渠道卡片渲染成"未接入",用户看到的就是"渠道不可用"。底层模型完全能用 ——
这是最容易被误判成"上游挂了"的一类假信号。

## 修法
- `list_platforms`:logged_in 改为遍历账号池(`poolList` + 凭据存在 + enabled≠false),
  旧单账号仅作兜底;顺带回 `account_id` / `accounts` 计数,让 UI 能区分同平台多账号;
- `status_page`:`accounts` 同样合并账号池(此前只回旧表,状态页里池接入的平台
  是空的 —— 同一根因的第二个出口);
- 新增 `__platforms_probe`(`list_platforms` 是 HTTP 路由处理器、不在 exports 里,
  判据走探针,与仓库既有做法一致);
- 回归判据 `TestPlatformsLoggedInFromAccountPool`:池里有凭据账号 ⇒ logged_in=true,
  且 account_id / account / accounts 回池数据。

## 顺带核实(不是代码缺陷)
- codearts 目录里 4 个模型(`deepseek-v4-flash-0731` / `glm-5.3-flash` /
  `deepseek-v4-pro-0813` / `CodeArts-Pangu`)实测上游 404
  `InferHub.002002009.404: The model is not registered` ⇒ 之前被手工停用是对的,
  已按死模型流程收口(不再回到可选列表);
- `GLM-5.3` 同样 404,已标失效并自动从源里剔除(日志:
  「模型已失效,已标记 codearts/GLM-5.3」);
- `deepseek-v4-flash` 早前的 429 是 TPM 限流(我自己连续压测造成),现已恢复 OK。
- 最终 codearts:**9 个模型,9/9 实测返回内容**。
2026-10-20 09:54:06 +08:00
b5c89fd3d7 chore(gui): 版本号 2.0.44(与内核同版本,桌面插件刷新判据依赖它) 2026-10-20 09:39:49 +08:00
7f193a997d fix(channels): trae 目录用错头族——IDE 端点必须发 IDE 头(此前恒回 2001)
## 真因:两套头混用
用户报「trae 还是无法从上游拉取,示例插件都可以」。用探针把**原始响应**打出来
才看清:目录端点 HTTP **200**,但 22 个 function 全部
`error_code 2001 "Config detail for function: X not found"` —— 于是远端目录恒空,
插件只能退回兜底表,用户侧表现成「模型不正确 / 渠道不可用」。

根因不是网络、不是账号、不是 body:是**头族用错了**。
- `traeCheckinHeaders` 面向 `api.trae.cn` 的签到/积分:VSCode UA + `X-Market-*`
  + `X-Lscbd-*` + `Vscode-Sessionid` + **派生**的 market device id;
- `traeIdeHeaders` 面向 `trae-api-cn.mchost.guru` 的目录/对话:`Trae/0.1.52` UA
  + `X-App-Id` + `X-Ide-Version(-Code)` + `Request-Traffic-Type` +
  **凭据里登录时的** `device_id`/`machine_id`。

目录刷新当时复用的是签到头(注释还写着"复用更安全"),发到 IDE 端点就被逐
function 判 2001。用 IDE 头直发同一端点:**HTTP 200 + 2.6 MB**、每个 function
都带 `config_info_list`。

修法(全部在插件内,内核零改动):
1. 新增 `traeIdeHeaders()`,与签到头**明确分开**,注释写清两套头的适用面;
2. 目录刷新(`sign="trae_solo"`)改用它,`Accept: application/json`;
3. 加判据 `__trae_headers_probe` + `TestTraeHeadersKeptSeparate`:钉死两套头的
   键集合互不越界(IDE 头必须有 X-App-Id/X-Machine-Id、不得有 X-Market-*;
   签到头反之)。这类"都能跑、但跑错了"的错只能靠契约钉死。

## 目录拉通后暴露的第二个问题:目录≠可调用
拿到真实目录(22 function / 93 条去重模型)后逐个实测,发现**同一个
config_name 在不同 function 里可用性不同**,而本插件适配器恒发
`solo_work_lite`:
- `kimi-k2.8-preview` / `deepseek-V3` / `glm-4.7` / `code-review-judge` /
  `multimodal_image_kimi-k2.5` → HTTP 200 但 **content 为空**;
- `sagitta` → 自称 Claude、`aquila` → 自称 ChatGPT、`custom_model_gpt-5` →
  自称 GPT-4 系列 ⇒ 这些是**未知 config 时上游悄悄回落默认模型** ——
  这正是"模型不正确"的真实来源。

于是补两道准入(都是**声明式**平台数据,不写死逻辑):
- `models_remote.admit_functions = { "solo_work_lite" }`:只收本适配器**会发**
  的那条通道里的模型(白名单只说"该通道在本端点可调用",不等于"适配器会发");
- `models_fallback_no_supplement = true`:远端目录可用时**不再补兜底表**
  (兜底表里 sagitta/aquila/glm-5/DeepSeek-V4-Flash 等在上游已无对应配置)。

结果:trae 可选模型 = **20 个**(glm-5.3 / glm-5.3-flash / glm-5.3-flashx /
glm-5.2 / kimi-k3 / kimi-k2.7-code / kimi-k2.6 / minimax-m3 / minimax-m3.1-flash /
qwen3.8-max / qwen-3.7-plus / step-5-preview / mimo-v2.6-{flash,pro} /
deepseek-v4.1-flash / DeepSeek-V4-{Flash,Pro}-Official / Doubao-Seed-2.1-{Pro,Turbo,Evolving}),
**20/20 逐个实测返回非空内容**。

## 回归测试
- `TestTraeHeadersKeptSeparate`(新):两套头键集合互斥(不联网即可复现该类回归)
- `TestTraeCustomModelSlotsAreNotOffered` / `TestTraeSourceOmitsCustomModelSlots`
  (上一版):custom_model_* 槽位不进 UI 也不进上游源
- 死模型判据:opencode 的 `has been deprecated` 文案阳/阴性(上一版)
2026-10-20 09:38:39 +08:00
6 changed files with 376 additions and 33 deletions

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "2.0.43",
"version": "2.0.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "2.0.43",
"version": "2.0.45",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "2.0.43",
"version": "2.0.45",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -0,0 +1,56 @@
package core
import "testing"
// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。
//
// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):
// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版
// 单账号的键;而现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号)。
// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入",
// 用户看到的正是"渠道不可用",而底层模型其实完全能用。
//
// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。
func TestPlatformsLoggedInFromAccountPool(t *testing.T) {
_, ps := poolCore(t)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts", "account_id": "acct-pool-1",
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
"nickname": "pool-nick",
}); err != nil {
t.Fatalf("account_add: %v", err)
}
// list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据
m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{})
if err != nil {
t.Fatalf("__platforms_probe: %v", err)
}
w, _ := m.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
arr, _ := j["platforms"].([]interface{})
if len(arr) == 0 {
t.Fatalf("platforms 不应为空")
}
var got map[string]interface{}
for _, raw := range arr {
p, _ := raw.(map[string]interface{})
if p["id"] == "codearts" {
got = p
}
}
if got == nil {
t.Fatalf("platforms 里找不到 codearts")
}
if got["logged_in"] != true {
t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got)
}
if got["account_id"] != "acct-pool-1" {
t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"])
}
if got["account"] != "pool-nick" {
t.Errorf("account 应回池里的昵称;实得 %#v", got["account"])
}
if s, _ := got["accounts"].(float64); s < 1 {
t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"])
}
}

View File

@ -120,23 +120,23 @@ func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarnes
// ① 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"]}},`+
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}}`+
`]},`+
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置。
// 另三个"必被过滤"的条目也放这里 —— 生产里适配器恒发这条通道,
// 硬性过滤的判据必须在这条通道上被验证到(放 solo_agent 会因为
// admit_functions 策略先行剔除,测试就测不到过滤逻辑了)。
`{"function":"solo_work_lite","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true},`+
`{"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}`+

View File

@ -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)
}
}
}

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", "__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", "__platforms_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",
},
@ -3760,6 +3780,23 @@ local function effectiveModels(provider)
-- 进源只会让客户端选中后报错(trae 的 custom_model_* 槽位就是这类)。
local unavailable = {}
for _, m in ipairs(plat.models_unavailable or {}) do unavailable[m] = true end
-- ★ admit_functions:只放行**本适配器真的会发**的通道里的模型。
--
-- 白名单(parse 里的 rankOf)只说明"该通道在本端点下可调用",不等于
-- "适配器会发它"。trae 适配器恒发 solo_work_lite,别的通道的模型经我们
-- 这条路发出去要么回空内容、要么被上游悄悄回落到默认模型(实测
-- kimi-k2.8-preview / deepseek-V3 空回,sagitta 自称 Claude、aquila 自称
-- GPT)—— 那正是用户说的"模型不正确"。
--
-- 放在这里而不是解析器里,是因为解析器还要负责**元数据合并**:同一模型在
-- solo_agent 里有思考档位、在 solo_work_lite 里没有,合并必须跨通道进行
-- (否则模型显示"未提供推理等级")。策略只决定"哪些 id 对外露出"。
local admit = nil
local mrAdmit = plat.models_remote and plat.models_remote.admit_functions
if type(mrAdmit) == "table" then
admit = {}
for _, f in ipairs(mrAdmit) do admit[f] = true end
end
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
local out, seen = {}, {}
-- 远端在前(权威顺序)
@ -3770,17 +3807,38 @@ 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 过的),**绝不**拿远端目录反推下架 ——
-- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。
local dead = deadModelIds(provider)
local metaAll = plugin.state.modelMeta and plugin.state.modelMeta[provider] or {}
local kept = {}
for _, id in ipairs(out) do
if not dead[id] and not unavailable[id] then kept[#kept+1] = id end
local keep = (not dead[id]) and (not unavailable[id])
if keep and admit then
local e = metaAll[id]
-- 有目录元数据 ⇒ 按它声明的通道判;没有元数据(兜底/手工源)⇒ 不拦,
-- 因为"目录没说"不等于"不可调用"。
if type(e) == "table" and type(e.chans) == "table" then
keep = false
for _, c in ipairs(e.chans) do
if admit[c] then keep = true break end
end
elseif type(e) == "table" and type(e.chan) == "string" and e.chan ~= "" then
keep = admit[e.chan] == true
end
end
if keep then kept[#kept+1] = id end
end
return kept
end
@ -4077,10 +4135,20 @@ local function parseRemoteModels(kind, body, provider)
local groups = rec.function_configs or rec.functionConfigs
if type(groups) == "table" then
local byId, chosenRank, order = {}, {}, {}
-- chansOf[id]:该 id 在**哪些通道**里出现过(合并只留一条元数据,但
-- "在哪些通道可发"是策略判定要用的信息 —— admit_functions 不能只看
-- 合并后那条的 chan:同模型常在有档位的 solo_agent 与我们要发的
-- solo_work_lite 同时出现,合并会选前者,导致误判"不可发")。
local chansOf = {}
for _, g in ipairs(groups) do
if type(g) == "table" then
local chan = rstr(g, "function", "Function")
-- 准入:未声明 function 或不在白名单 → 整组丢弃
--
-- ⚠️ 「白名单」只回答"该通道在本适配器的推理端点下**可调用**";
-- "适配器会不会发这条通道"是另一回事,由 effectiveModels 的
-- models_remote.admit_functions 过滤(那是产品策略,不该写进解析器:
-- 解析器的职责是把目录如实解出来,含元数据合并)。
if chan and rankOf[chan] then
local list = g.config_info_list or g.ConfigInfoList
if type(list) == "table" then
@ -4096,6 +4164,8 @@ local function parseRemoteModels(kind, body, provider)
-- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝)
if e.custom == true then ok = false end
if ok then
chansOf[e.id] = chansOf[e.id] or {}
chansOf[e.id][chan] = true
local inc = byId[e.id]
if inc == nil then
byId[e.id] = e
@ -4131,7 +4201,14 @@ local function parseRemoteModels(kind, body, provider)
-- 上层只能显示"未提供推理等级")。
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]
local entry = byId[id]
if type(entry) == "table" and chansOf[id] then
local list = {}
for c in pairs(chansOf[id]) do list[#list+1] = c end
table.sort(list)
entry.chans = list
end
plugin.state.modelMeta[provider][id] = entry
end
end
@ -4227,10 +4304,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 +4320,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
@ -9043,14 +9122,42 @@ end
function plugin.list_platforms(req)
local out = {}
for _, p in ipairs(allPlatforms()) do
-- ★ logged_in 必须看**账号池**,不能只看旧的单账号键。
--
-- 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):这里原本
-- `credentialOf("channels:" .. id)` —— 那是**旧版单账号**的保险库键;
-- 现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号),
-- 于是**所有**通过账号池接入的平台都被判成未登录(buddy 之所以显示已登录,
-- 是因为它还留着一条旧格式凭据)。UI 据此把渠道卡片渲染成"未接入",
-- 用户看到的就是"渠道不可用",而底层模型其实完全能用。
local logged, first, claimed = false, nil, nil
for _, a in ipairs(poolList(p.id)) do
if a.enabled ~= false and credentialOf(a.credentialRef) ~= nil then
logged = true
if first == nil then first = a end
if claimed == nil and a.last_claim ~= nil then claimed = a.last_claim end
end
end
-- 兼容旧单账号:池里没有时回落到 state.accounts + 旧凭据键
local acct = accountOf(p.id) or {}
local has = credentialOf("channels:" .. p.id)
local hasLegacy = credentialOf("channels:" .. p.id) ~= nil
if not logged and hasLegacy then logged = true end
if first == nil and (hasLegacy or acct.account ~= nil) then
first = { id = acct.id, nickname = acct.account, enabled = acct.enabled }
end
out[#out + 1] = {
id = p.id, name = p.name, flow = p.flow,
logged_in = has ~= nil,
account = acct.account,
enabled = acct.enabled,
last_claim = acct.last_claim,
logged_in = logged,
-- 账号展示:池里第一个可用账号优先(id 是 12 位 hex,UI 用来区分同平台多账号)
account_id = first and first.id or nil,
account = (first and first.nickname) or acct.account,
enabled = first and first.enabled ~= false or acct.enabled,
last_claim = claimed or acct.last_claim,
accounts = (function()
local n = 0
for _, a in ipairs(poolList(p.id)) do if a.enabled ~= false then n = n + 1 end end
return n
end)(),
}
end
return { status = 200, json = { platforms = out } }
@ -9389,8 +9496,32 @@ end
function plugin.status_page(req)
local r = __llmsproxy_kernel.call("status", {})
-- accounts 必须**合并账号池**:只回 state.accounts(旧单账号表)时,
-- 通过账号池接入的平台在这里是空的,UI 的状态页于是显示"没有账号",
-- 与 platforms 的 logged_in 一样会让人误判成"渠道不可用"
-- (同一根因的两个出口,2026-10-20 一起修)。
local accounts = {}
for k, v in pairs(plugin.state.accounts or {}) do accounts[k] = v end
for _, p in ipairs(allPlatforms()) do
local list = {}
for _, a in ipairs(poolList(p.id)) do
list[#list + 1] = {
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
expiresAt = a.expiresAt, last_claim = a.last_claim,
has_credential = credentialOf(a.credentialRef) ~= nil,
}
end
if #list > 0 then
local base = accounts[p.id] or {}
local merged = {}
for k, v in pairs(base) do merged[k] = v end
merged.accounts = list
merged.count = #list
accounts[p.id] = merged
end
end
return { status = 200, json = {
accounts = plugin.state.accounts,
accounts = accounts,
log = plugin.state.log,
kernel_ready = r and r.ready or false,
} }
@ -9803,6 +9934,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 +13217,44 @@ function plugin.__list_sources_probe()
return { json = { sources = out } }
end
-- plugin.__platforms_probe:只给判据用 —— 原样返回 `platforms` 路由的数据。
--
-- 为什么需要它:`list_platforms` 是 plugin.http 的路由处理器,**不在**
-- plugin.exports 里(exports 是给 WebUI 直接调用的白名单),判据无法直接
-- Call 到它。走探针是仓库既有做法(见 channels_remove_source_perm_test.go
-- 对 `status` 的同类处理),避免为了测试把只读状态处理器塞进 exports。
function plugin.__platforms_probe(args)
local r = plugin.list_platforms(nil) or {}
return { json = r.json or {} }
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 +13302,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