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 仍可选中。

(cherry picked from commit 2113bfc125)
This commit is contained in:
root
2026-10-19 22:32:18 +08:00
parent ff7051d608
commit 711ccdeea8
11 changed files with 585 additions and 21 deletions

View File

@ -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
@ -9916,7 +9939,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
@ -11814,6 +11898,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 = [];
@ -11830,6 +11921,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 对齐参考;无端点平台如实显示不可用)
@ -12184,6 +12277,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)