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:
root
2026-10-19 22:32:18 +08:00
parent c9f34538ec
commit 2113bfc125
11 changed files with 585 additions and 21 deletions

View File

@ -55,6 +55,13 @@ function adapter.transform_request(raw_body)
-- private one.
req.thinking = nil
req.extra_body = nil
-- ★★ buddy(腾讯 CodeBuddy)上游**只支持流式**:body.stream=false 会被
-- 400 拒绝(实测 2026-10-19):
-- 11101: Non-stream chat request is currently not supported
-- 与 Accept: text/event-stream 头无关(头早已在发),判据是 body。
-- 故强制 stream=true,非流式请求由内核的"客户端要非流式、上游回流式"
-- 路径消化(transform_response 需能聚合 SSE,见下)。
req.stream = true
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
-- 否则上游回 400:
-- {"code":11150,"msg":"the reasoning effort value is not supported
@ -92,7 +99,92 @@ function adapter.transform_request(raw_body)
return json.encode(req)
end
-- aggregate_sse(raw):把上游 SSE(data: 行流)聚合成一个非流式 OpenAI 响应。
-- 强制 stream=true 后,内核的非流式路径会在这里收到完整 SSE 原文——必须聚合,
-- 否则 json.decode 失败原样透传,Go 侧 unmarshal 报 invalid character 'd'。
-- 聚合 content / reasoning_content / tool_calls(按 index 拼接分片)/
-- finish_reason / usage;无可解析帧时返回 nil(调用方回退原样)。
local function aggregate_sse(raw)
local content, reasoning = {}, {}
local finish, usage, id, model, created = nil, nil, nil, nil, nil
local tools_by_index = {}
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
id = id or d.id
model = model or d.model
created = created or d.created
if type(d.usage) == "table" then usage = d.usage end
local ch = d.choices and d.choices[1]
if type(ch) == "table" then
if type(ch.finish_reason) == "string" and ch.finish_reason ~= "" then
finish = ch.finish_reason
end
local dl = ch.delta
if type(dl) == "table" then
if type(dl.content) == "string" then content[#content + 1] = dl.content end
if type(dl.reasoning_content) == "string" then
reasoning[#reasoning + 1] = dl.reasoning_content
end
if type(dl.tool_calls) == "table" then
for _, tc in ipairs(dl.tool_calls) do
if type(tc) == "table" and tc.index ~= nil then
local slot = tools_by_index[tc.index]
if slot == nil then
slot = { id = tc.id, type = tc.type or "function",
["function"] = { name = "", arguments = "" } }
tools_by_index[tc.index] = slot
end
if tc.id then slot.id = tc.id end
local fn = tc["function"]
if type(fn) == "table" then
if fn.name then slot["function"].name = slot["function"].name .. fn.name end
if fn.arguments then slot["function"].arguments = slot["function"].arguments .. fn.arguments end
end
end
end
end
end
end
end
end
end
end
if not seen then return nil end
local msg = { role = "assistant", content = table.concat(content) }
local rc = table.concat(reasoning)
if rc ~= "" then msg.reasoning_content = rc end
local ordered = {}
for i = 0, 63 do
if tools_by_index[i] then ordered[#ordered + 1] = tools_by_index[i] end
end
if #ordered > 0 then msg.tool_calls = ordered end
local out = {
id = id or "", object = "chat.completion", created = created or 0,
model = model or "",
choices = { { index = 0, message = msg,
finish_reason = (finish and finish ~= "") and finish or "stop" } },
}
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)
-- 上游只会回流式(transform_request 强制 stream=true)。客户端要非流式时
-- 内核把完整 SSE 原文交到这里,先聚合再走统一转换。
if type(raw_body) == "string"
and (raw_body:match("^%s*data:") or raw_body:match("[\r\n]data:")) then
local aggregated = aggregate_sse(raw_body)
if aggregated ~= nil then raw_body = aggregated end
end
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
@ -147,11 +239,10 @@ function adapter.transform_stream_chunk(raw_chunk)
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
-- Sanitize on the way OUT too: an id this upstream happily minted (it
-- does not validate them) becomes a landmine once the client replays
-- it to a Claude upstream. Only the first fragment of a streamed call
-- carries an id; later argument fragments have none and are untouched.
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
-- Do not serialize an empty Lua table as JSON `{}`. That shape is
-- neither an OpenAI tool-call array nor an omitted field, and several
-- agent clients reject it on the final chunk.
for _, tc in ipairs(delta.tool_calls) do
if type(tc) == "table" and tc.id ~= nil then
tc.id = safe_tool_id(tc.id)

View File

@ -1,8 +1,13 @@
local adapter = {}
adapter.name = "opencodezen"
adapter.version = "1.0.0"
adapter.version = "1.1.0"
adapter.endpoint = "/chat/completions"
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
adapter.hidden = true
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
--
@ -99,7 +104,7 @@ local function now_ms(meta)
return t * 1000
end
local function rand_id(prefix, ms, kind)
local function rand_id(prefix, ms, kind, affinity)
local seg
if kind == "session" then
seg = (ID_K - ms * 4096) % (MASK48 + 1)
@ -107,7 +112,15 @@ local function rand_id(prefix, ms, kind)
seg = (ms * 16) % (MASK48 + 1)
end
-- %012x:12 位十六进制,不足补零
return prefix .. string.format("%012x", seg) .. rand_tail(14)
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
local tail
if type(affinity) == "string" and affinity ~= "" then
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
else
tail = rand_tail(14)
end
return prefix .. string.format("%012x", seg) .. tail
end
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
@ -120,6 +133,41 @@ end
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
-- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求**
-- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶——
-- 1) meta.client_session(pi 等真会话客户端自带,会话恒定)
-- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何
-- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定)
-- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致)
-- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。
local function conversation_fingerprint(body)
if type(body) ~= "string" or body == "" then return "" end
local ok, req = pcall(json.decode, body)
if not ok or type(req) ~= "table" then return "" end
for _, m in ipairs(req.messages or {}) do
if type(m) == "table" and m.role == "user" then
local c = m.content
if type(c) == "string" then
if c ~= "" then return c end
elseif type(c) == "table" then
for _, part in ipairs(c) do
if type(part) == "table" and part.type == "text" and part.text then
return part.text
end
end
end
return ""
end
end
return ""
end
local function session_affinity_key(meta)
local cs = meta and meta.client_session
if type(cs) == "string" and cs ~= "" then return cs end
return conversation_fingerprint(meta and meta.body)
end
local function session_bucket_ms(meta)
local ms = now_ms(meta)
return ms - (ms % 60000)
@ -129,7 +177,9 @@ function adapter.build_headers(meta)
local ms = now_ms(meta)
local bucket = session_bucket_ms(meta)
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
local sid = rand_id("ses_", bucket, "session")
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
return {
["User-Agent"] = adapter.headers["User-Agent"],
["x-opencode-client"] = "cli",

View File

@ -253,7 +253,7 @@ function adapter.transform_stream_chunk(raw_chunk)
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)