mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels,provider): 渠道模型不可用 + 额度异常的三个根因
## 背景
用户报三问题:① channel 注册的模型无法使用;② 额度显示依旧异常;
③ 其他渠道应也存在类似问题。三条全部核实并给出结论(③ 见文档)。
## ① 网关解不开 `源名:模型名` 限定名(internal/provider/registry.go)
`ResolvePinned` 用 `strings.IndexAny(model, "-:/")` 找**第一个**分隔符。
而渠道源名形如 `channels-trae-b8dd6b3df30e`(**含 `-`**),于是
`channels-trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro` 被切成
src="channels"、model="trae-b8dd6b3df30e:Doubao-Seed-2.1-Pro"
⇒ 源名永不匹配 ⇒ `Resolve()` 返回 nil
⇒ 客户端看到 `model "..." is not configured`。
**为什么这个写法必须支持**:本实例有 **44 个模型被多源同时提供**
(`glm-5.2` 由 6 个源提供),WebUI 的模型下拉框用 `源名:模型名` 作为
option value 是**有意设计** —— 用户必须能指定"从哪个源调用"。
修法:抽 `splitPinned()`,**按已知源名做最长前缀匹配**(源名是已知集合,
这是唯一可靠办法),保留旧 `-:/` 兜底以兼容非源名形态。
`EffectiveModel` 同步修(同一拆分规则,避免两处漂移)。
## ② trae 适配器三连错(internal/lua/plugins/channels.lua)
三个错误逐层叠加,每个都被上一层掩盖:
**(a) 没有 `build_headers`** ⇒ 内核兜底发 `Authorization: Bearer`,
而 trae 推理端点要 `Cloud-IDE-JWT` + 设备身份头 ⇒
401「We're sorry, but we are not able to authenticate you」。
→ 与上一轮「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点漏了)。
教训:修"某一族请求的鉴权"时,必须把该族的**所有出口**列出来逐个对齐。
**(b) header 常量的值是错的**(照参考 trae-product.ts 的 TRAE 配置逐项核对):
· `X-App-Id` 抄成 `6eefa6c9`,真实是 `6eefa01c-1036-4c7e-9ca5-d891f63bfcd8`
· `app_version_code` 用了 `0.1.52`,真实是**日期式** `20260811`
⇒ 上游 `4001 bad request: binding: expr_path=app_version_code,
cause=parameter type does not match binding data`
**(c) 流式转换完全不对**:TRAE 是 **SOLO 自定义 SSE**:
event:output → data:{"response":"###","reasoning_content":null,...}
正文在 `response`,推理在 `reasoning_content`。
· 套 `__prelude.openai_stream_chunk` ⇒ 无 `choices` ⇒ `empty stream`
· 改成 OpenAI 外壳 `{choices:[{delta}]}` **仍然空** —— 内核
`json.Unmarshal(unified, &UnifiedChunk)` 解的是**扁平结构**,外壳字段全被丢弃
⇒ 必须输出扁平 `{content, reasoning_content, usage, done, finish_reason}`。
⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
因此判别帧类型只能靠**内容特征**(`response` / `reasoning_content` /
`finish_reason` / `prompt_tokens`),拿不到 event 名。
## ③ 余额恒为 0(同文件 balance.total_fn)
读的是 `p.credits_limit`(pack **顶层**)—— 该字段**根本不存在**。
真实路径是 `pack.entitlement_base_info.quota.credits_limit`
(参考 trae-credits.ts:341)+ `pack.usage.credits_amount`。
实测上游:`免费`(无该字段) / `每月登录赠送`(500) / `签到奖励`(100) ⇒ 实际 600。
修法:读嵌套路径 + 保留顶层兼容 + `usage_summary.{total,consumed}_amount` 兜底。
修后余额 600 → 实测 598.516(真实扣减)。
## ④ 夹具与生产形状不一致(internal/core/channels_trae_test.go)
夹具原先返回**扁平** `{"Token":...}`,与生产 `{"Result":{"Token":...}}` 不符
⇒ 「代码只读顶层」这个缺陷在夹具里**恰好是对的**,所有 trae 判据都绿。
**夹具形状错 = 判据必然虚假通过**。这是 ExchangeToken 缺陷藏了两轮的原因。
## 判据(新增,变异 4/4 咬住)
- `internal/provider/registry_pinned_name_test.go`(4 用例)
- `internal/core/channels_trae_chat_headers_test.go`(3 条)
- `internal/core/channels_trae_solo_sse_test.go`(2 条)
- `internal/core/channels_trae_credits_hdr_test.go`(1 条)
- `internal/core/channels_trae_exchange_shape_test.go`(2 条)
## 文档
`docs/channel-repair-plan.md`:现状快照(生产实测)+ 各渠道缺陷矩阵 +
后续计划(P0-P4)。**如实记录**:其余 4 个渠道(zcode/cline/gemini/qoder)
有同类缺陷风险,但**账号数均为 0、无法做生产验证**。
## 生产已实测
- `channels-trae-...:Doubao-Seed-2.1-Pro` → 80 帧,正文 27 + 推理 299
- `channels-trae-...:glm-5.2` / `channels-buddy-...:hy4-preview` → OK
- `credits/status` → active:true, dailyCredit:100, todayCheckedIn:true
- `credits/balances` → total:598.516 credits
This commit is contained in:
@ -116,7 +116,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", "__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", "__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", "__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", "__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" }
|
||||
|
||||
|
||||
|
||||
@ -153,6 +153,21 @@ local lobsteraiClientVersion
|
||||
local expiringWindowDays
|
||||
local fetchAccountJSON
|
||||
|
||||
-- ★★ 这批前向声明必须在 PLATFORMS **之前**:平台表里的 headers_fn / claim_fn
|
||||
-- 闭包会在**运行时**调用这些函数,而闭包捕获的是**词法作用域里的那个变量**。
|
||||
-- 若声明写在后面(例如 line ~5479),PLATFORMS 段里的引用会被解析成**全局**
|
||||
-- `traeCheckinHeaders`(nil),而后面的 `local` 是**另一个变量**,赋不到它
|
||||
-- ⇒ 调用点永远拿到 nil。
|
||||
--
|
||||
-- 真实缺陷(2026-10-17 用户报「显示额度不可用」):
|
||||
-- trae 的 checkin_status.headers_fn 调 traeCheckinHeaders(...) 返回 nil,
|
||||
-- fetchAccountJSON 于是退回默认 `Authorization: Bearer <token>`
|
||||
-- ⇒ 上游回「not able to authenticate you」;而同一账号的**签到领取是成功的**
|
||||
-- (claim_fn 那条路同样拿到 nil,但它内部有 Bearer 兜底且 claim 端点接受)。
|
||||
-- ⇒ 教训:Lua 的 local 只对**其后的**代码可见;闭包不是延迟绑定的魔法。
|
||||
local traeCheckinHeaders, traeFixNickname
|
||||
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
|
||||
|
||||
local PLATFORMS = {
|
||||
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
|
||||
-- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
|
||||
@ -233,6 +248,10 @@ local PLATFORMS = {
|
||||
-- TRAE 非 OpenAI 兼容,用**本插件自装**的 SOLO 适配器
|
||||
-- (见 TRAE_SOLO_ADAPTER_SRC)。端点由适配器声明 /api/agent/v3/llm_utils_chat。
|
||||
adapter = "channels-trae",
|
||||
-- ★ 让 syncAccountSource 把凭据里的设备身份放进源的 meta ——
|
||||
-- trae 的推理头需要 X-Uid / X-Device-Id / X-Machine-Id 派生,
|
||||
-- 而适配器沙箱里读不到凭据(只能用内核传进来的 meta)。
|
||||
source_meta_from_credential = true,
|
||||
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
|
||||
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
|
||||
models = {
|
||||
@ -260,6 +279,12 @@ local PLATFORMS = {
|
||||
checkin_status = {
|
||||
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/status",
|
||||
method = "POST", body = "{}",
|
||||
-- ★ 鉴权方案与 checkin 相同:Cloud-IDE-JWT + 设备身份头。
|
||||
-- 参考 trae.ts 端点表:签到状态 / 积分余额 皆 Cloud-IDE-JWT。
|
||||
-- headers_fn 在**文件末尾的绑定循环**里接上(auth="cloud-ide-jwt" 段):
|
||||
-- 内联定义会因作用域顺序拿不到 traeCheckinHeaders / platformById,
|
||||
-- 闭包捕获 nil、错误被 pcall 吞掉 ⇒ **静默退回 Bearer** ⇒
|
||||
-- 上游「not able to authenticate you」⇒ 用户看到「额度不可用」。2026-10-17。
|
||||
status_fn = function(rec)
|
||||
return {
|
||||
active = rec.enable == true,
|
||||
@ -272,14 +297,54 @@ local PLATFORMS = {
|
||||
balance = {
|
||||
url = "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage",
|
||||
method = "POST", body = '{"require_usage":true,"req_source":2}',
|
||||
-- 同上:积分余额同为 Cloud-IDE-JWT;headers_fn 在末尾绑定循环里接上。
|
||||
-- ★★ 字段在**两层嵌套**里,不在 pack 顶层:
|
||||
-- pack.entitlement_base_info.quota.credits_limit ← 额度上限
|
||||
-- pack.usage.credits_amount ← 已用
|
||||
-- 参考 trae-credits.ts:341 `const quota = base.quota` +
|
||||
-- `readNumber(quota,'credits_limit')` 逐字如此。
|
||||
--
|
||||
-- 真实缺陷(2026-10-17 用户报「额度显示依旧异常」):
|
||||
-- 我此前读的是 `p.credits_limit`(pack 顶层)—— 该字段**根本不存在**
|
||||
-- ⇒ 每个包都算 0 ⇒ 总余额恒 0,而账号里其实有 600(每月登录 500 + 签到 100)。
|
||||
-- 实测上游响应(2026-10-17):
|
||||
-- pack[0] 免费 quota 无 credits_limit → 跳过
|
||||
-- pack[1] 每月登录赠送 quota.credits_limit=500 usage={}
|
||||
-- pack[2] 签到奖励 quota.credits_limit=100 usage={}
|
||||
-- usage_summary: {total_amount:600, consumed_amount:0}
|
||||
total_fn = function(rec)
|
||||
local sum = 0
|
||||
local list = rec.user_entitlement_pack_list or rec.userEntitlementPackList or {}
|
||||
if type(list) ~= "table" then return 0 end
|
||||
for _, p in ipairs(list) do
|
||||
local lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0
|
||||
local amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0
|
||||
sum = sum + math.max(0, lim - amt)
|
||||
local base = p.entitlement_base_info or p.entitlementBaseInfo
|
||||
local quota = (type(base) == "table" and (base.quota or base.Quota)) or nil
|
||||
local lim, amt = 0, 0
|
||||
if type(quota) == "table" then
|
||||
lim = tonumber(quota.credits_limit) or tonumber(quota.creditsLimit) or 0
|
||||
end
|
||||
-- 兼容:少数响应把额度放在 pack 顶层(保留旧路径,不丢)
|
||||
if lim == 0 then
|
||||
lim = tonumber(p.credits_limit) or tonumber(p.creditsLimit) or 0
|
||||
end
|
||||
local usage = p.usage or p.Usage
|
||||
if type(usage) == "table" then
|
||||
amt = tonumber(usage.credits_amount) or tonumber(usage.creditsAmount) or 0
|
||||
end
|
||||
if amt == 0 then
|
||||
amt = tonumber(p.credits_amount) or tonumber(p.creditsAmount) or 0
|
||||
end
|
||||
if lim > 0 then sum = sum + math.max(0, lim - amt) end
|
||||
end
|
||||
-- 兜底:所有包都没有 credits_limit 时,用 usage_summary 的总量减消耗。
|
||||
-- (实测「免费」包就是这样:quota 里只有 enable_* 布尔,无额度字段。)
|
||||
if sum == 0 then
|
||||
local us = rec.usage_summary or rec.usageSummary
|
||||
if type(us) == "table" then
|
||||
local tot = tonumber(us.total_amount) or tonumber(us.totalAmount) or 0
|
||||
local used = tonumber(us.consumed_amount) or tonumber(us.consumedAmount) or 0
|
||||
if tot > 0 then sum = math.max(0, tot - used) end
|
||||
end
|
||||
end
|
||||
return sum
|
||||
end,
|
||||
@ -3934,7 +3999,25 @@ local function syncAccountSource(provider, accountId)
|
||||
-- 不透明字符串用 —— 不需要内核知道任何厂商知识。
|
||||
if plat.endpoint then src.endpoint = plat.endpoint end
|
||||
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
|
||||
if plat.headers_extra then src.headers = plat.headers_extra end
|
||||
if plat.headers_extra then src.headers = plat.headers_extra end
|
||||
-- ★ 把**设备身份**随源带给适配器:有些平台的头不是简单 Bearer,
|
||||
-- 需要 uid / device_id / machine_id 派生(trae 的 X-Uid / X-Device-Id /
|
||||
-- X-Machine-Id)。适配器沙箱里**读不到凭据**,只能靠 meta 传。
|
||||
--
|
||||
-- 真实缺陷(2026-10-17 用户报「无法使用 channel 注册的模型」):
|
||||
-- 此前 meta 为空 ⇒ 适配器没法构造 Cloud-IDE-JWT 头族 ⇒ 内核兜底发
|
||||
-- `Authorization: Bearer <access_token>` ⇒ 上游 401
|
||||
-- 「We're sorry, but we are not able to authenticate you」。
|
||||
-- 这与「额度不可用」是**同一根因的两个出口**(额度端点修了、推理端点没修)。
|
||||
if plat.source_meta_from_credential then
|
||||
local cmeta = credentialFull(acct.credentialRef)
|
||||
if type(cmeta) == "table" then
|
||||
src.meta = {}
|
||||
for _, mk in ipairs({ "uid", "device_id", "machine_id", "enterprise_id" }) do
|
||||
if cmeta[mk] ~= nil and cmeta[mk] ~= "" then src.meta[mk] = cmeta[mk] end
|
||||
end
|
||||
end
|
||||
end
|
||||
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
|
||||
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
|
||||
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
|
||||
@ -5454,10 +5537,6 @@ end
|
||||
-- 之前**占坑。此前它们是全局函数,靠「全局表在运行时才解析」侥幸工作;
|
||||
-- 改成 local 后这个隐式依赖就断了 —— 表现为
|
||||
-- attempt to call global 'traeCallback' (a nil value)。
|
||||
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
|
||||
|
||||
local traeCheckinHeaders, traeFixNickname
|
||||
|
||||
function plugin.poll_login(req)
|
||||
local id = (req and req.query and req.query.id) or ""
|
||||
sweepSessions()
|
||||
@ -8807,11 +8886,35 @@ traeCallback = function(plat, q, s)
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return nil, nil, "TRAE ExchangeToken 响应无法解析"
|
||||
end
|
||||
local access = rec.Token or rec.token or rec.AccessToken or rec.access_token
|
||||
-- ★★ 上游把凭据放在 **Result 里**,不是顶层:
|
||||
-- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...","RefreshToken":"..."}}
|
||||
-- 实测(2026-10-17 用真实 refreshToken 直打 ExchangeToken):HTTP 200,
|
||||
-- 顶层只有 ResponseMetadata + Result、**没有** Token。
|
||||
-- 我原先读 `rec.Token` ⇒ 恒 nil ⇒ 报「响应缺少 Token」;用户只看到「登录失败」,
|
||||
-- 完全看不出是解包层级错了。参考 parseTraeExchangeResponse 同样先取
|
||||
-- `data.Result ?? data.result`。
|
||||
-- ⚠️ 顶层也兜(上游可能改版),但**优先 Result**。
|
||||
local r2 = rec.Result or rec.result
|
||||
if type(r2) ~= "table" then r2 = rec end
|
||||
local access = r2.Token or r2.token or r2.AccessToken or r2.access_token
|
||||
or rec.Token or rec.token
|
||||
if not access or access == "" then
|
||||
return nil, nil, "TRAE ExchangeToken 响应缺少 Token"
|
||||
return nil, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)"
|
||||
end
|
||||
return packTrae(access), account, nil
|
||||
-- ★ 轮换后的 refreshToken 必须留下:旧 refresh 已被上游作废
|
||||
-- (参考注释明说「换新 access 并**轮换** refreshToken」)。不存新值 ⇒ 下次续期必失败。
|
||||
local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken
|
||||
local expireAt = r2.TokenExpireAt or rec.TokenExpireAt
|
||||
local packed = packTrae(access)
|
||||
if (newRefresh and newRefresh ~= "") or expireAt then
|
||||
local okc, cred = pcall(json.decode, packed)
|
||||
if okc and type(cred) == "table" then
|
||||
if newRefresh and newRefresh ~= "" then cred.refresh_token = newRefresh end
|
||||
if expireAt then cred.expires_at = math.floor((tonumber(expireAt) or 0) / 1000) end
|
||||
packed = json.encode(cred)
|
||||
end
|
||||
end
|
||||
return packed, account, nil
|
||||
end
|
||||
|
||||
-- 分支 2:无 refreshToken,用 userJwt.Token 兜底
|
||||
@ -8995,6 +9098,31 @@ end
|
||||
do
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
if p.checkin and p.checkin.auth == "cloud-ide-jwt" then
|
||||
-- ★★ 给**同一平台的** checkin_status / balance 接上同一套头。
|
||||
--
|
||||
-- 为什么必须在这里而不是表内联:PLATFORMS 定义在文件最前(~170 行),
|
||||
-- 那时 traeCheckinHeaders(~9030 才赋值)还是 nil、platformById 也只是
|
||||
-- 尚未定义的 local ⇒ 闭包捕获成 nil ⇒ 上层 pcall 吞掉错误
|
||||
-- ⇒ **静默退回 `Authorization: Bearer`** ⇒ 上游回
|
||||
-- 「not able to authenticate you」,用户看到「额度不可用」。
|
||||
-- 而同一账号的**签到领取是成功的**(claim_fn 就在本循环绑定)——
|
||||
-- 「能签到、额度却不可用」这个矛盾,根源就是两处头不同源。
|
||||
-- 真实缺陷:2026-10-17 用户报「显示额度不可用」。
|
||||
local mkHeaders = function(plat)
|
||||
return function(accessToken, cred)
|
||||
local c = (type(cred) == "table") and cred or {}
|
||||
if not c.access_token then c.access_token = accessToken end
|
||||
-- 传 plat 本身(不是 nil):traeCheckinHeaders 用 plat.ide_version
|
||||
-- 取 App-Version,传 nil 会抛错并被上层 pcall 静默吞掉。
|
||||
return traeCheckinHeaders(plat, c, nil)
|
||||
end
|
||||
end
|
||||
for _, specName in ipairs({ "checkin_status", "balance" }) do
|
||||
local spec = p[specName]
|
||||
if type(spec) == "table" and not spec.headers_fn then
|
||||
spec.headers_fn = mkHeaders(p)
|
||||
end
|
||||
end
|
||||
p.claim_fn = function(plat, tok, args)
|
||||
local cred = (args and args.credential) or { access_token = tok }
|
||||
local uid = (args and args.uid) or cred.uid
|
||||
@ -9148,6 +9276,7 @@ function adapter.transform_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
-- CodeArts 的流式是 OpenAI 兼容格式,直接复用共享前导。
|
||||
return __prelude.openai_stream_chunk(raw_chunk)
|
||||
end
|
||||
|
||||
@ -9186,6 +9315,66 @@ adapter.name = "channels-trae"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/api/agent/v3/llm_utils_chat"
|
||||
|
||||
-- ★★ build_headers:推理请求的 Cloud-IDE-JWT 头族。
|
||||
--
|
||||
-- 参考 trae.ts traeSOLOHeaders(逐项对齐)。端点表明确:**对话也是 Cloud-IDE-JWT**,
|
||||
-- 不是 Bearer。没有这个钩子时内核兜底发 `Authorization: Bearer <api_key>`
|
||||
-- ⇒ 上游 401「We're sorry, but we are not able to authenticate you」
|
||||
-- ⇒ 表现为「channel 注册的模型全都不能用」。
|
||||
--
|
||||
-- 真实缺陷(2026-10-17):本适配器只有 4 个 transform、**没有 build_headers**,
|
||||
-- 而同一平台的签到/额度端点也漏了同一套头 —— 同一根因的两个出口。
|
||||
--
|
||||
-- meta 里可选带 uid / device_id / machine_id(由 syncAccountSource 从凭据取),
|
||||
-- 因为适配器沙箱**读不到凭据**。
|
||||
function adapter.build_headers(meta)
|
||||
local tok = ""
|
||||
local uid, dev, mid = "", "", ""
|
||||
if type(meta) == "table" then
|
||||
if type(meta.api_key) == "string" then tok = meta.api_key end
|
||||
local src = type(meta.source) == "table" and meta.source or {}
|
||||
local m = type(src.meta) == "table" and src.meta or {}
|
||||
uid = tostring(m.uid or "")
|
||||
dev = tostring(m.device_id or "")
|
||||
mid = tostring(m.machine_id or "")
|
||||
end
|
||||
-- ★★ 这些值是**准入条件**,不是装饰。逐项照参考 trae-product.ts 的 TRAE 配置:
|
||||
-- appId = 6eefa01c-1036-4c7e-9ca5-d891f63bfcd8 (完整 UUID,
|
||||
-- 我第一版抄成 "6eefa6c9" —— 前缀都不对)
|
||||
-- ideVersionCode = 20260811 (**日期式**,不是 0.1.52)
|
||||
-- ideVersion = 0.1.52
|
||||
-- deviceBrand = Apple
|
||||
-- osVersion = macOS 15.7.4
|
||||
-- userAgent = Trae/0.1.52 (参考用这个,不是 VSCode 那串)
|
||||
--
|
||||
-- 实测错误(2026-10-17):`app_version_code` 传 "0.1.52" 时上游回
|
||||
-- 4001 bad request: binding: expr_path=app_version_code,
|
||||
-- cause=parameter type does not match binding data
|
||||
-- ⇒ 它要的是**日期式数字串**,版本号格式会被类型校验拒掉。
|
||||
local h = {
|
||||
["Content-Type"] = "application/json",
|
||||
["Accept"] = "text/event-stream",
|
||||
["User-Agent"] = "Trae/0.1.52",
|
||||
["Authorization"] = "Cloud-IDE-JWT " .. tok,
|
||||
["X-Cloudide-Token"] = tok,
|
||||
["X-Ide-Token"] = tok,
|
||||
["X-App-Id"] = "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
|
||||
["X-App-Version"] = "default",
|
||||
["X-Ide-Version"] = "0.1.52",
|
||||
["X-Ide-Version-Code"] = "20260811",
|
||||
["X-App-Version-Code"] = "20260811",
|
||||
["X-Ide-Version-Type"] = "stable",
|
||||
["X-Device-Type"] = "macos",
|
||||
["X-OS-Version"] = "macOS 15.7.4",
|
||||
["X-Device-Brand"] = "Apple",
|
||||
["Request-Traffic-Type"] = "prod",
|
||||
}
|
||||
if uid ~= "" then h["X-Uid"] = uid end
|
||||
if dev ~= "" then h["X-Device-Id"] = dev end
|
||||
if mid ~= "" then h["X-Machine-Id"] = mid end
|
||||
return h
|
||||
end
|
||||
|
||||
local TRAE_DEFAULT_MODEL = "glm-5.2"
|
||||
local TRAE_FUNCTION = "solo_work_lite"
|
||||
local TRAE_MAX_MODE_TYPE = 1
|
||||
@ -9320,9 +9509,78 @@ function adapter.transform_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
return __prelude.openai_stream_chunk(raw_chunk)
|
||||
end
|
||||
-- ★★ TRAE 是 **SOLO 自定义 SSE**,且内核要求适配器返回**扁平的 UnifiedChunk**
|
||||
-- (`content` / `reasoning_content` / `usage` / `done` / `finish_reason`),
|
||||
-- **不是** OpenAI 的 `{choices:[{delta:{...}}]}` 外壳。
|
||||
-- 见 __prelude.openai_stream_chunk 的实现:它把 choices[0].delta 摊平后返回。
|
||||
--
|
||||
-- 实测上游真实帧(2026-10-17 直接打 chat 端点抓包):
|
||||
-- event:output → data:{"response":"###","reasoning_content":null,"tool_calls":null}
|
||||
-- 正文在 **response**,推理在 **reasoning_content**(思考模式下先出 78 帧推理、
|
||||
-- 再出 40 帧正文)。
|
||||
--
|
||||
-- 真实缺陷(用户报「无法使用 channel 注册的模型」):
|
||||
-- ① 我原先直接 `return __prelude.openai_stream_chunk(raw_chunk)` ——
|
||||
-- SOLO 帧没有 `choices` ⇒ 每帧都解析不出内容 ⇒ `empty stream`。
|
||||
-- ② 修了 ① 之后我返回 OpenAI 外壳 `{choices:[{delta:{...}}]}` —— 仍不对:
|
||||
-- 内核把整个 JSON 解码成 UnifiedChunk(扁平结构),外壳字段全被丢弃 ⇒
|
||||
-- 91 帧全是空 content。
|
||||
--
|
||||
-- ⚠️ 内核只把 `data:` 行交给适配器(`event:` 行在 provider.go 被丢弃),
|
||||
-- 所以只能**按内容特征**判别帧类型。
|
||||
local ok, raw = pcall(json.decode, raw_chunk)
|
||||
if not ok or type(raw) ~= "table" then return "" end
|
||||
|
||||
-- ① done:有 finish_reason、无正文
|
||||
if raw.finish_reason ~= nil and raw.response == nil then
|
||||
return json.encode({ done = true, finish_reason = tostring(raw.finish_reason) })
|
||||
end
|
||||
-- ② token_usage:整个对象是 usage
|
||||
if raw.response == nil and raw.reasoning_content == nil and raw.tool_calls == nil
|
||||
and (raw.prompt_tokens ~= nil or raw.completion_tokens ~= nil or raw.total_tokens ~= nil) then
|
||||
return json.encode({ done = false, usage = {
|
||||
prompt = tonumber(raw.prompt_tokens) or 0,
|
||||
completion = tonumber(raw.completion_tokens) or 0,
|
||||
total = tonumber(raw.total_tokens) or 0,
|
||||
} })
|
||||
end
|
||||
-- ③ output:正文 / 推理 / 工具调用(**扁平**输出)
|
||||
local unified = { done = false }
|
||||
local has = false
|
||||
if type(raw.response) == "string" and raw.response ~= "" then
|
||||
unified.content = raw.response
|
||||
has = true
|
||||
end
|
||||
if type(raw.reasoning_content) == "string" and raw.reasoning_content ~= "" then
|
||||
unified.reasoning_content = raw.reasoning_content
|
||||
has = true
|
||||
end
|
||||
if type(raw.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, c in ipairs(raw.tool_calls) do
|
||||
if type(c) == "table" then
|
||||
local copy = {}
|
||||
for k, v in pairs(c) do copy[k] = v end
|
||||
-- 参考 normalizeTraeToolCalls:function_call → function,清理 SOLO 专属字段
|
||||
if type(copy.function_call) == "table" and copy["function"] == nil then
|
||||
copy["function"] = copy.function_call
|
||||
copy.function_call = nil
|
||||
end
|
||||
if type(copy["function"]) == "table" then
|
||||
copy["function"].namespace = nil
|
||||
copy["function"].partial_arguments = nil
|
||||
end
|
||||
tcs[#tcs + 1] = copy
|
||||
end
|
||||
end
|
||||
if #tcs > 0 then
|
||||
unified.tool_calls = tcs
|
||||
has = true
|
||||
end
|
||||
end
|
||||
if not has then return "" end
|
||||
return json.encode(unified)
|
||||
end
|
||||
function adapter.transform_error(status, body)
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
@ -11457,7 +11715,43 @@ function plugin.__set_expiring_window_probe(args)
|
||||
return { json = { ok = true, days = expiringWindowDays() } }
|
||||
end
|
||||
|
||||
-- plugin.__set_legacy_cred_probe:只给测试用 —— 造一条"升级前"的非池凭据。
|
||||
|
||||
|
||||
function plugin.__cred_headers_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local cred = {}
|
||||
local okc, dec = pcall(json.decode, tostring(args.credential or ""))
|
||||
if okc and type(dec) == "table" then cred = dec end
|
||||
local tok = cred.access_token or ""
|
||||
local out = {}
|
||||
for _, which in ipairs({ "checkin_status", "balance" }) do
|
||||
local spec = plat[which]
|
||||
if spec then
|
||||
local h = nil
|
||||
local hfn = spec.headers_fn or plat.headers_fn
|
||||
if hfn then
|
||||
local okf, got = pcall(hfn, tok, cred)
|
||||
if okf and type(got) == "table" then h = got end
|
||||
end
|
||||
if not h then h = { Authorization = "Bearer " .. tok, ["Content-Type"] = "application/json" } end
|
||||
out[which] = h
|
||||
end
|
||||
end
|
||||
return { json = out }
|
||||
end
|
||||
|
||||
function plugin.__set_pool_cred_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local ref = "channels:" .. plat.id .. ":" .. tostring(args.account_id or "")
|
||||
local _, serr = __llmsproxy_secrets.set(ref, tostring(args.credential or ""))
|
||||
if serr then return { json = { error = serr } } end
|
||||
return { json = { ok = true, ref = ref } }
|
||||
end
|
||||
|
||||
function plugin.__set_legacy_cred_probe(args)
|
||||
args = args or {}
|
||||
local ref = "channels:" .. tostring(args.platform or "")
|
||||
|
||||
Reference in New Issue
Block a user