Files
ModelRouter/internal/lua/adapters/buddy.lua
root 2113bfc125 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 仍可选中。
2026-10-19 22:32:18 +08:00

293 lines
14 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

local adapter = {}
adapter.name = "buddy"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
-- to the Anthropic Messages API internally, so they inherit Anthropic's
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
-- is replayed to every other source, so one such id kills every Claude slot at
-- once and an AUTO request falls through all tiers.
--
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
-- matching tool_call_id are rewritten identically within one request. A
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
-- well-behaved traffic is byte-identical to before.
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
local TOOL_ID_MAX = 64
local function safe_tool_id(id)
if type(id) ~= "string" or id == "" then return id end
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
if clean == id and #clean <= TOOL_ID_MAX then
return clean
end
local digest = string.sub(sha256_hex(id), 1, 8)
local keep = TOOL_ID_MAX - #digest - 1
if #clean > keep then clean = string.sub(clean, 1, keep) end
return clean .. "_" .. digest
end
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
--
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
local NEED_EFFORT = {
["glm-5.1"] = "medium",
}
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
-- The gateway's private control fields are GONE from the body (v2): the
-- neutral `thinking` field carries that intent and each adapter maps it in
-- its own protocol terms. These upstreams have no thinking switch at all,
-- so the field is simply dropped here — which is what the nil-ing below
-- used to do, except the field being dropped is no longer the kernel's
-- 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
-- by the current model"}
-- 实测(2026-10-16,逐字对照实验):
-- glm-5.1 裸(无该字段) → 400 11150 ✗
-- glm-5.1 + reasoning_effort:medium → 200 ✓
-- glm-5.1 空串 → 400 11150 ✗
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
-- glm-5.1 **独有**的要求。
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
-- 所有模型。
if NEED_EFFORT[req.model or ""] and
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
req.reasoning_effort == nil then
req.reasoning_effort = NEED_EFFORT[req.model]
end
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
if msg.tool_call_id ~= nil then
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
end
if type(msg.tool_calls) == "table" then
for _, tc in ipairs(msg.tool_calls) do
if type(tc) == "table" and tc.id ~= nil then
tc.id = safe_tool_id(tc.id)
end
end
end
end
end
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)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
end
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
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)
end
end
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
--
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
-- "模型不回答",极易误判成模型坏了或额度不足。
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
--
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
-- 是产品码(codebuddy)。
function adapter.build_headers(meta)
local h = {
["Accept"] = "text/event-stream",
["Content-Type"] = "application/json",
["X-Domain"] = "copilot.tencent.com",
["X-Product"] = "SaaS",
["X-Product-Code"] = "codebuddy",
["User-Agent"] = "CodeBuddyIDE/1.106.1",
}
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
h["Authorization"] = "Bearer " .. meta.api_key
end
return h
end
return adapter