mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器
生产实测逐渠道排查(2026-10-19):
* qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串;
qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test
走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包
{access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。
* trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧),
transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报
invalid character 'e'。补 aggregate_solo_sse 聚合。
* buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制
stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。
* provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为
tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。
* opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML);
兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。
opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹
分桶),保留免费层门禁四要素。
* 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的
隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。
* channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号
闪现在 opencode 卡片下的渲染竞态。
* channels request_end hook:挂接丢失的 isModelGone/recordDeadModel ——
真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进
deadModels,不再每轮 404 仍可选中。
This commit is contained in:
@ -902,10 +902,14 @@ local PLATFORMS = {
|
||||
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
|
||||
-- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。
|
||||
endpoint = "/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
|
||||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)。
|
||||
-- ★ 端点必须是 /zen/v1/models(带 UA+x-opencode-client 头):裸打
|
||||
-- /v1/models 回的是 HTML 网页(2026-10-19 实测),id_list 解析必空。
|
||||
models_remote = {
|
||||
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
|
||||
auth = "bearer",
|
||||
endpoints = { { url = "https://opencode.ai/zen/v1/models", method = "GET",
|
||||
headers = { ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
||||
["x-opencode-client"] = "cli" } } },
|
||||
auth = "none",
|
||||
parse = "id_list",
|
||||
},
|
||||
adapter = "openai",
|
||||
@ -916,16 +920,24 @@ local PLATFORMS = {
|
||||
free_source = "capability",
|
||||
-- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义
|
||||
-- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。
|
||||
-- ⚠️ 2026-10-19 实测目录更新(/zen/v1/models):
|
||||
-- * space-bunny-free 已 410 下架(官方指路 minimax-m3.1-flash-free)
|
||||
-- * nemotron-3-ultra-free 已不在目录
|
||||
-- * 新增 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", "space-bunny-free", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
|
||||
"nemotron-3.5-lightning-free", "deepseek-v4-flash", "deepseek-v4.1-flash",
|
||||
"big-pickle", "beam-free", "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",
|
||||
"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", "space-bunny-free", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
|
||||
"nemotron-3.5-lightning-free",
|
||||
"big-pickle", "beam-free", "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", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
|
||||
},
|
||||
},
|
||||
|
||||
@ -2105,9 +2117,20 @@ local function qoderClaimCampaign(plat, cred, campaignId)
|
||||
end
|
||||
|
||||
-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。
|
||||
-- ★ 凭据形状必须兼容**裸 token**:qoder 是 PKCE 设备码流且无 register 第二跳,
|
||||
-- complete_login 存进凭据库的就是裸 access_token 字符串(credentialOf 走裸快路
|
||||
-- 返回 (tok, nil, nil)),credArgs.credential 因此是 nil。此前在这里硬要求
|
||||
-- table,导致"测试通过(token 路径)、领取失败(缺凭据对象)"的自相矛盾。
|
||||
-- 活动接口只要 Bearer token,裸 tok 非空时就地包一层。
|
||||
local function qoderClaim(plat, tok, args)
|
||||
local cred = (args and args.credential) or nil
|
||||
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
|
||||
if type(cred) ~= "table" then
|
||||
if type(tok) == "string" and tok ~= "" then
|
||||
cred = { access_token = tok }
|
||||
else
|
||||
return nil, "缺少凭据对象"
|
||||
end
|
||||
end
|
||||
local parsed, lerr = qoderLoadCampaigns(plat, cred)
|
||||
if not parsed then return { done = true, kind = "failed", message = lerr } end
|
||||
|
||||
@ -9925,7 +9948,68 @@ function adapter.transform_request(raw_body)
|
||||
return json.encode(body)
|
||||
end
|
||||
|
||||
-- aggregate_solo_sse(raw):把 SOLO 的 SSE(event:/data: 混合行)聚合成**末帧语义**
|
||||
-- 的非流式响应对象。SOLO 对非流式请求也回 SSE(真实缺陷:body 以 event:metadata
|
||||
-- 开头),而内核只把 data: 行交给流式路径——非流式路径收到的是**完整原始 body**
|
||||
-- (含 event: 行),直接 json.decode 必然失败(invalid character 'e')。
|
||||
-- 聚合规则:拼接所有 data: 帧的 response/reasoning_content,取最后的 finish_reason
|
||||
-- 与 usage。无任何可解析帧时返回 nil(调用方回退原样)。
|
||||
local function aggregate_solo_sse(raw)
|
||||
local content, reasoning = {}, {}
|
||||
local finish, usage = nil, nil
|
||||
local seen_tool_calls = nil
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
if type(d.response) == "string" and d.response ~= "" then
|
||||
content[#content + 1] = d.response
|
||||
end
|
||||
if type(d.reasoning_content) == "string" and d.reasoning_content ~= "" then
|
||||
reasoning[#reasoning + 1] = d.reasoning_content
|
||||
end
|
||||
if d.finish_reason ~= nil then finish = tostring(d.finish_reason) end
|
||||
if d.prompt_tokens ~= nil or d.completion_tokens ~= nil or d.total_tokens ~= nil then
|
||||
usage = { prompt_tokens = tonumber(d.prompt_tokens) or 0,
|
||||
completion_tokens = tonumber(d.completion_tokens) or 0,
|
||||
total_tokens = tonumber(d.total_tokens) or 0 }
|
||||
end
|
||||
-- SOLO 把 tool_calls 放在帧上;聚合时保留**最后一个非空**数组
|
||||
if type(d.tool_calls) == "table" and #d.tool_calls > 0 then
|
||||
seen_tool_calls = d.tool_calls
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local out = {
|
||||
choices = { { index = 0,
|
||||
message = { role = "assistant", content = table.concat(content) },
|
||||
finish_reason = finish or "stop" } },
|
||||
}
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then out.choices[1].message.reasoning_content = rc end
|
||||
if seen_tool_calls then out.choices[1].message.tool_calls = seen_tool_calls end
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- SOLO 上游对非流式请求也回 SSE(带 event: 前缀的帧)。识别出 SSE 就地聚合成
|
||||
-- OpenAI 非流式形状再走统一转换;识别不出(真 JSON)原样透传。
|
||||
if type(raw_body) == "string"
|
||||
and (raw_body:match("^%s*event:") or raw_body:match("[\r\n]event:") or raw_body:match("^data:")) then
|
||||
local aggregated = aggregate_solo_sse(raw_body)
|
||||
if aggregated ~= nil then raw_body = aggregated end
|
||||
end
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
@ -11823,6 +11907,13 @@ plugin.ui = {
|
||||
const s = state.statuses.find((x) => x.id === id) || {};
|
||||
q("#ch-detail-name").textContent = s.name || id;
|
||||
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
|
||||
// ★ 切渠道立即清空旧渠道的账号/模型/余额状态。
|
||||
// 此前不清:renderAccounts/renderModels 是异步的,await 前的空窗里 DOM 仍挂着
|
||||
// 上一个渠道的账号行(实测:从 qoder 切到 opencode,qoder 的账号带着
|
||||
// "上次领取 · 未开通"文案闪现在 opencode 卡片下,被当成 opencode 的账号)。
|
||||
// 账号行还挂操作按钮(测试/删除),误点会打到错误平台。
|
||||
state.accounts = []; state.models = [];
|
||||
q("#ch-accts").innerHTML = '<p class="muted" style="font-size:13px">加载中…</p>';
|
||||
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
|
||||
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
|
||||
const acts = [];
|
||||
@ -11839,6 +11930,8 @@ plugin.ui = {
|
||||
|
||||
async function renderAccounts(id) {
|
||||
const j = await call("channels/account/list", "POST", { platform: id });
|
||||
// 竞态防护:await 期间用户可能已切到别的渠道,旧响应不得覆盖新渲染
|
||||
if (state.selected !== id) return;
|
||||
const accts = j.accounts || [];
|
||||
state.accounts = accts;
|
||||
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
|
||||
@ -12193,6 +12286,20 @@ function plugin.on_request_end(payload)
|
||||
local msg = tostring(payload.error or payload.message or payload.error_message or "")
|
||||
local kind = plugin.term_error_kind(msg)
|
||||
if kind then plugin.mark_account_term_error(provider, kind) end
|
||||
-- ★ 模型级死亡记录(照参考 dead-model-store.ts):判据命中"下架"措辞时记一笔。
|
||||
-- 此前 isModelGone/recordDeadModel 只有测试 probe 在调(基础设施丢失挂接点),
|
||||
-- 真实 404(如 codearts 的 GLM-5.3 "The model is not registered")永远不会
|
||||
-- 进 deadModels,模型在 UI 里一直可选中、每轮 404。
|
||||
if msg ~= "" then
|
||||
local modelId = tostring(payload.model or payload.model_id or "")
|
||||
if modelId ~= "" and isModelGone(msg) then
|
||||
local isnew = recordDeadModel(provider, modelId, msg)
|
||||
if isnew then
|
||||
logEvent("模型已失效,已标记 " .. provider .. "/" .. modelId, "warn", provider)
|
||||
markDirty()
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
function plugin.credits_tier_probe(args)
|
||||
|
||||
Reference in New Issue
Block a user