mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 渠道鉴权头三修 + 源名契约 + 夹具形状判据
P1-1 anthropic 基座补 build_headers(x-api-key + anthropic-version) 根因:基座只有静态 anthropic-version、没有 build_headers ⇒ 内核兜底发 Authorization: Bearer,而 Anthropic 协议要 x-api-key。 ★ 证据冲突(计划 P1-1 原文一半有误):zcode 的 /zcode-plan/anthropic/v1/messages 虽然**说 Anthropic 协议**,鉴权却是 Bearer <zcode_jwt>(参考 zcode-transport.ts:121-125;全仓 grep x-api-key = 0 次命中)。故 zcode 必须**显式覆盖** build_headers 回到 Bearer, 否则基座的正向修复会把它从"对"改成"错"。 内核改动:internal/provider/provider.go —— 兜底 Bearer 让位给 x-api-key, 避免同一请求带两套鉴权(部分 Anthropic 兼容上游对歧义鉴权直接 401)。 P1-2 channels-cline 的 build_headers 补 workos: 前缀 根因:参考 cline.ts 发 `Bearer workos:<jwt>`,而池里存的是第二步 register 换回的裸 token ⇒ 之前无 build_headers,内核兜底发裸 Bearer。 幂等:已带前缀(含大小写变体)不重复拼。 P1-3 channels-gemini 只确认不改 参考 gemini.ts:353-366 同样是 OAuth Bearer + 身份五头、刻意不发 x-goog-api-key ⇒ 我们现状本来就对。加判据把这个结论钉住,防下轮照 P1-1 的模子把它改坏。 P2 源名契约:仅 [A-Za-z0-9_-],禁 : 与 / 根因:: 与 / 是限定名分隔符,出现在源名里会让 `源名:模型名` 有两种都 自圆其说的切法,同一请求串在不同部署下可能路由到不同源,且**不报错**。 校验加在写入口 core.normalizeSource(硬拒,API 400);启动时只告警, 避免存量带 : 的源名让升级 brick 掉整个网关。 解析入口 splitPinned 不做校验(解析不该拒绝已有数据)。 P3 夹具形状判据(本轮最重要的一条) 根因:trae 的 Result 信封缺陷在扁平夹具下所有判据全绿,真实登录却报 「缺少 Token」—— 夹具形状错 ⇒ 判据必然虚假通过。 通用形状层:shapeFamilies 登记表,每族给 real/naive/fact/eval/assert, 断言 fact(real) 且 !fact(naive) 且 assert(eval(real))。 刻意**不比较 eval 输出形状**:trae 余额实现合法保留顶层兼容路径, 拿输出比对会把合法兼容判成错误;能区分真假的是 fact(额度在哪一层)。 另有仓库扫描:对特征明确的退化形状静态反查,需退化夹具时写 SHAPE-OK 豁免。 判据:11 个文件 / 30 用例。全部变异已验证会报红(9 处)。
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", "__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" }
|
||||
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", "__balance_total_probe", "__trae_exchange_parse_probe" }
|
||||
|
||||
|
||||
|
||||
@ -8813,6 +8813,36 @@ local function jsonField(raw, key)
|
||||
return v[key] or ""
|
||||
end
|
||||
|
||||
-- parseTraeExchangeBody(body):ExchangeToken 响应体 → (access, newRefresh, expireAt, err)。
|
||||
--
|
||||
-- ★ 为什么抽成纯函数:判据要能**直接咬真实解析器**。此前 trae 的判据是
|
||||
-- 「造 HTTP 假上游 → 走完整回调」,结果一次形状不符的**夹具**(扁平
|
||||
-- `{"Token":...}`)把这个真缺陷(只读顶层)完美掩盖了两轮。夹具形状错
|
||||
-- ⇒ 判据必然虚假通过。抽出来之后,判据拿真实上游响应原文直接打它。
|
||||
--
|
||||
-- 真实上游形状(2026-10-17 实测,HTTP 200):
|
||||
-- {"ResponseMetadata":{...},"Result":{"Token":"eyJ...",
|
||||
-- "RefreshToken":"...","TokenExpireAt":1800000000000}}
|
||||
-- 顶层只有 ResponseMetadata + Result、**没有** Token。
|
||||
-- 参考 parseTraeExchangeResponse 同样先取 `data.Result ?? data.result`。
|
||||
-- 顶层也兜(上游可能改版),但**优先 Result**。
|
||||
local function parseTraeExchangeBody(body)
|
||||
local ok, rec = pcall(json.decode, body or "")
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return nil, nil, nil, "TRAE ExchangeToken 响应无法解析"
|
||||
end
|
||||
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, nil, "TRAE ExchangeToken 响应缺少 Token(Result 与顶层都没有)"
|
||||
end
|
||||
local newRefresh = r2.RefreshToken or r2.refreshToken or rec.RefreshToken or rec.refreshToken
|
||||
local expireAt = r2.TokenExpireAt or rec.TokenExpireAt or r2.expireAt
|
||||
return access, newRefresh, expireAt, nil
|
||||
end
|
||||
|
||||
traeCallback = function(plat, q, s)
|
||||
q = q or {}
|
||||
local userJwt = q.userJwt or ""
|
||||
@ -8882,29 +8912,21 @@ traeCallback = function(plat, q, s)
|
||||
if resp.status ~= 200 then
|
||||
return nil, nil, "TRAE ExchangeToken HTTP " .. tostring(resp.status)
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return nil, nil, "TRAE ExchangeToken 响应无法解析"
|
||||
end
|
||||
-- ★★ 上游把凭据放在 **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(Result 与顶层都没有)"
|
||||
end
|
||||
-- ★ 解包交给 parseTraeExchangeBody(上面那个纯函数)。
|
||||
-- **单一定义**:判据拿真实上游响应原文直接打同一个函数,不另写副本 ——
|
||||
-- 否则夹具/副本与生产漂移时判据会继续绿(trae 就是这么藏了两轮)。
|
||||
--
|
||||
-- 上游把凭据放在 **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`。
|
||||
local access, newRefresh, expireAt, perr = parseTraeExchangeBody(resp.body)
|
||||
if perr then return nil, nil, perr end
|
||||
-- ★ 轮换后的 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)
|
||||
@ -10618,6 +10640,51 @@ end
|
||||
adapter.name = "channels-zcode"
|
||||
adapter.version = "1.0.0"
|
||||
|
||||
-- ============================================================================
|
||||
-- build_headers:zcode 的 Messages 端点是 **Bearer**,不是 x-api-key
|
||||
-- ============================================================================
|
||||
-- ★★ 这一层**必须显式覆盖**基座的 build_headers,不能靠继承。
|
||||
--
|
||||
-- 基座(anthropic.lua)现在按 Anthropic 协议发 `x-api-key`,而 zcode 的
|
||||
-- `/api/v1/zcode-plan/anthropic/v1/messages` 虽然**说着 Anthropic 协议**,
|
||||
-- 鉴权却是 **Authorization: Bearer <zcode_jwt>**。
|
||||
--
|
||||
-- 参考实现是唯一权威(vendor/channel-pack 实测逆向):
|
||||
-- src/zcode-transport.ts:121-125 buildChannelRequest()
|
||||
-- headers = buildZcodeHeaders(credential, { json: true })
|
||||
-- headers.Authorization = `Bearer ${token}` ← token = zcode_jwt
|
||||
-- src/zcode-upstream.ts:83-108 buildZcodeHeaders()
|
||||
-- 只发 User-Agent / HTTP-Referer / X-Device-Mid / anthropic-version 等,
|
||||
-- Authorization 是**可选**注入项,**全程没有 x-api-key**
|
||||
-- 全仓 `grep -rn "x-api-key" src/` ⇒ **0 次命中**
|
||||
--
|
||||
-- ⇒ 协议(Anthropic Messages 形状)与鉴权(Bearer)在这里是**两个独立维度**:
|
||||
-- 基座对的是「任何说 Anthropic 协议的上游」,zcode 对的是「它自己那家」。
|
||||
-- 若这里不覆盖:基座会发 x-api-key,而内核 buildHeaders 不会再补 Bearer
|
||||
-- ⇒ zcode 一有账号就 401,与「什么都没发」等价。
|
||||
--
|
||||
-- 同时保留基座给的 anthropic-version(协议头,跟鉴权无关),
|
||||
-- 并用基座同一套键名取凭据,避免两处各写一份取值规则。
|
||||
local function zcode_api_key(meta)
|
||||
if type(meta) ~= "table" then return "" end
|
||||
if type(meta.api_key) == "string" and meta.api_key ~= "" then return meta.api_key end
|
||||
-- 兜底:个别调用方只给 source.meta.api_key(与基座/kimicode 同一约定)
|
||||
local sm = type(meta.source) == "table" and meta.source.meta or nil
|
||||
if type(sm) == "table" and type(sm.api_key) == "string" then return sm.api_key end
|
||||
return ""
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["anthropic-version"] = "2023-06-01",
|
||||
}
|
||||
local key = zcode_api_key(meta)
|
||||
if key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 身份块构造
|
||||
-- ============================================================================
|
||||
@ -10863,6 +10930,50 @@ function adapter.transform_request(raw_body) return raw_body end
|
||||
function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end
|
||||
function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end
|
||||
|
||||
-- build_headers:Cline 的**推理**端点要 `Authorization: Bearer workos:<jwt>`。
|
||||
--
|
||||
-- 为什么必须在这里补前缀(而不是登录处):参考实现 cline.ts 的发头函数直接
|
||||
-- 拼 `workos:${token}`,而 Cline **第二步 register** 把 WorkOS token 换成了
|
||||
-- Cline 自己的 token(我们的池里存的就是**后者**,不带前缀)。实测/参考都
|
||||
-- 要求网关侧把 `workos:` 当**协议前缀**重新拼上,否则上游只回泛化的认证失败
|
||||
-- (与 trae 修前同型:头发了,但形式不对)。
|
||||
--
|
||||
-- ★ 幂等:已经带 `workos:` 的 token **不再重复拼**(`workos:workos:xxx` 必错,
|
||||
-- 而两种凭据在池里都可能出现:旧数据是完整的带前缀串,新登录的是裸 token)。
|
||||
-- ★ 前缀必须**大小写不敏感**地判(上游文档写 `workos:`,实际发过 `WorkOS:`
|
||||
-- 的客户端也能用),但补出来的一律是小写形式。
|
||||
local WORKOS_PREFIX = "workos:"
|
||||
|
||||
local function with_workos_prefix(tok)
|
||||
if type(tok) ~= "string" or tok == "" then return "" end
|
||||
if string.lower(string.sub(tok, 1, #WORKOS_PREFIX)) == WORKOS_PREFIX then
|
||||
return tok
|
||||
end
|
||||
return WORKOS_PREFIX .. tok
|
||||
end
|
||||
|
||||
-- 导出给判据用(不是内核契约的一部分):判据要直接咬**同一个**拼装函数,
|
||||
-- 不能在 Go 侧自算一遍前缀规则(那样真实函数被改坏时判据会继续绿)。
|
||||
adapter.with_workos_prefix = with_workos_prefix
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local key = ""
|
||||
if type(meta) == "table" then
|
||||
if type(meta.api_key) == "string" then key = meta.api_key end
|
||||
if key == "" and type(meta.source) == "table"
|
||||
and type(meta.source.meta) == "table"
|
||||
and type(meta.source.meta.api_key) == "string" then
|
||||
key = meta.source.meta.api_key
|
||||
end
|
||||
end
|
||||
local h = { ["Content-Type"] = "application/json" }
|
||||
local tok = with_workos_prefix(key)
|
||||
if tok ~= "" then
|
||||
h["Authorization"] = "Bearer " .. tok
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
|
||||
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
|
||||
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
|
||||
@ -11694,6 +11805,43 @@ function plugin.credits_tier_probe(args)
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__trae_exchange_parse_probe:只给测试/诊断用 —— 把一段**上游响应原文**
|
||||
-- 交给真实的 parseTraeExchangeBody,返回解出的字段。
|
||||
--
|
||||
-- 为何必须有这个缝隙:P3 的「Result 信封」判据要咬**真实解析器**。
|
||||
-- 若判据自己在 Go 侧再写一份解包逻辑,真实函数被改坏时它会继续绿
|
||||
-- (这正是 trae 那个缺陷藏了两轮的原因:夹具形状错与实现错恰好互相兼容)。
|
||||
function plugin.__trae_exchange_parse_probe(args)
|
||||
args = args or {}
|
||||
local access, newRefresh, expireAt, err = parseTraeExchangeBody(tostring(args.body or ""))
|
||||
if err then return { json = { error = err } } end
|
||||
return { json = { access = access, refresh = newRefresh, expire_at = expireAt } }
|
||||
end
|
||||
|
||||
-- plugin.__balance_total_probe:只给测试/诊断用 —— 把一段**上游响应原文**
|
||||
-- 交给该平台真实的 `balance.total_fn`,返回它算出的数字。
|
||||
--
|
||||
-- 为何需要:余额口径(嵌套 quota vs pack 顶层)曾经算错过一次 —— 读了一个
|
||||
-- **根本不存在的字段** ⇒ 余额恒 0,而判据当时是「造 HTTP 假上游 + 走完整
|
||||
-- credits_balances」,URL 覆盖/凭据 ref/路径匹配任一环错就会让「没发请求」
|
||||
-- 伪装成「余额算错」(耗了三轮)。本探针直接咬**被测对象**:同一个 total_fn。
|
||||
function plugin.__balance_total_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { json = { error = "未知平台" } } end
|
||||
local spec = plat[args.endpoint or "balance"]
|
||||
if type(spec) ~= "table" or type(spec.total_fn) ~= "function" then
|
||||
return { json = { error = "该端点未声明 total_fn" } }
|
||||
end
|
||||
local ok, rec = pcall(json.decode, tostring(args.body or ""))
|
||||
if not ok or type(rec) ~= "table" then
|
||||
return { json = { error = "夹具不是合法 JSON 对象" } }
|
||||
end
|
||||
local okf, total = pcall(spec.total_fn, rec)
|
||||
if not okf then return { json = { error = "total_fn 抛错: " .. tostring(total) } } end
|
||||
return { json = { total = tonumber(total) } }
|
||||
end
|
||||
|
||||
-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。
|
||||
function plugin.__set_lock_permanent_probe(args)
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
@ -12188,6 +12336,11 @@ function plugin.__adapter_install_probe(args)
|
||||
trae = plugin._trae_adapter,
|
||||
gemini = plugin._gemini_adapter,
|
||||
zcode = plugin._zcode_adapter,
|
||||
-- ★ cline / qoder 此前**不在探针输出里** —— 于是「装了但没生效」这个
|
||||
-- 最坑的故障态在这两个适配器上完全不可观测(本轮修 cline 的 workos:
|
||||
-- 前缀时才发现)。装上就该看得见。
|
||||
cline = plugin._cline_adapter,
|
||||
qoder = plugin._qoder_adapter,
|
||||
} }
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user