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

@ -0,0 +1,86 @@
# 渠道问题清单(2026-10-19 晚生产实测)
> 逐项修复。每项标注:现象 / 根因 / 状态。
## P1 trae 非流式 500(unmarshal unified response: invalid character 'e')
- **现象**:`stream:false` 打 `channels-trae:*` 必现 500,body 以 `event:metadata data:{...}` 开头。
- **根因**:trae SOLO 上游**对非流式请求也回 SSE**(且帧带 `event:` 前缀)。
插件自装的 `channels-trae` 适配器 `transform_response` 直接 `__prelude.openai_response(raw_body)`,
不识别 SSE;而内核只把 `data:` 行交给流式适配器,非流式路径把**整个 body**(含 `event:` 行)
塞给 transform_response → json.decode 失败 → 原样返回 → Go 侧 unmarshal 报 `invalid character 'e'`。
(`internal/lua/adapters/trae.lua` 有 SSE 聚合逻辑,但 channels 平台用的是**插件内嵌**
`TRAE_SOLO_ADAPTER_SRC`(channels.lua:9744),两份代码不同步。)
- **修法**:`TRAE_SOLO_ADAPTER_SRC.transform_response` 加 SSE 聚合(复用流式帧解析,
聚合成一个非流式响应),并把 `internal/lua/adapters/trae.lua` 的聚合逻辑对齐。
- **状态**:⬜
## P2 buddy(CodeBuddy)非流式 400(11101 Non-stream not supported)
- **现象**:`stream:false` 打 `channels-buddy:*` 上游 400 `11101: Non-stream chat request is currently not supported`。
- **根因**:腾讯 codebuddy 网关**只支持流式**。适配器 `transform_request` 没有强制 `stream=true`,
非流式请求原样透传被上游拒绝。buddy 的 `Accept: text/event-stream` 头已发,但 body.stream 仍是 false。
- **修法**:`buddy.lua transform_request` 强制 `req.stream = true`;`transform_response` 增加
SSE 聚合兜底(内核非流式路径收到 SSE 时聚合)。参考 opencodezen.lua 的既有做法(门禁第 4 条)。
- **状态**:⬜
## P3 buddy 偶发 provider busy(429)+ 11133 参数拒绝
- **现象**:连续请求 `channels-buddy` 间歇返回 `provider busy`(source 并发槽 max_concurrent=1
被占满)与上游 `11133: the request parameters were rejected`。
- **根因**:① max_concurrent=1 是**刻意的**(channels.lua:4332 注释:并发配额按模型计),busy
是正常自保;② 11133 与空 tool_calls 修复(未提交的 buddy.lua 改动)相关,待部署后观察。
- **状态**:观察项,非缺陷。
## P4 Qoder 领取失败(缺少凭据对象)——已修复
- **现象**:`channels/claim` 报 `{"error":"缺少凭据对象"}`,而 account_test 正常。
- **根因**:qoder 是 PKCE 设备码流且无 register 第二跳,落库凭据是**裸 token 字符串**;
`qoderClaim` 硬要求 `type(cred)=="table"`。
- **修法**:`qoderClaim` 兼容裸 token(非空时包 `{access_token=tok}`);
回归测试 `TestQoderClaimAcceptsBareTokenCredential`。
- **状态**:✅ 已修复并部署生产(claim 现在返回真实上游业务结论)。
## P5 Qoder/OpenCode 渠道页账号串台(UI 渲染竞态)
- **现象**:从 qoder 切到 opencode 详情页,qoder 的账号行闪现在 opencode 卡片下。
- **根因**:`renderDetail()` 切渠道时不清空 `#ch-accts`,`renderAccounts` 是异步的,
空窗期 DOM 仍挂旧渠道账号行;且慢的旧请求返回会覆盖新渠道数据。
- **修法**:① renderDetail 立即清空账号/模型区并置"加载中";② renderAccounts/renderModels
加代际校验(`state.selected !== id` 时丢弃响应)。
- **状态**:✅ 已改(channels.lua),待部署。
## P6 CodeArts「完全不可用」——多数模型实际可用
- **现象**:用户报"codearts 完全不可用"。实测逐模型探测:
- 可用:openpangu-2.0-flash/pro、GLM-5.2、glm-5.3、kimi-k2.6-vl、Qwen3-VL-235B、glm-5.2-sft-harmony
- 429(限流非下架):deepseek-v4.1-flash、deepseek-v4-flash、openpangu-2.0-pro(间歇)
- 404(真下架,需标 disabled):GLM-5.3 报 `InferHub.002002009.404: The model is not registered`
- **附带发现**:`GLM-5.3`(大写变体)不在 deadModels 记录里——`recordDeadModel` 只被
`__record_dead_probe` 调用,**request_end hook 没接 isModelGone**(583976f 引入的
基础设施丢了挂接点)。需要把 on_request_end 补上 dead-model 记录。
- **凭据**:expiresAt 已过期(15:05),`account/refresh` 续期成功(refreshable=true)。
建议把「凭据到期自动续期」纳入定时任务检查。
- **状态**:⬜(待补 dead-model 挂接 + GLM-5.3 标 disabled)
## P7 CodeBuddy 海外账号 / Qoder 国内账号
- **现象**:CodeBuddy 登录只走国内(copilot.tencent.com),Qoder 登录只走国际
(openapi.qoder.sh / qoder.com)。用户要求两边都能选区。
- **根因**:平台表单区硬编码。buddy 的 state-poll 端点与 Qoder 的 PKCE 端点都锁死一个区域。
- **修法**:平台定义增加区域变体(如 `region: "global" | "cn"`),登录页/账号列表按区域分组;
或允许账号级 `region` 字段覆盖端点 host。
- **状态**:⬜(需求确认后实施)
## P8 商汤(Raccoon)扫码登录「链接无效」
- **现象**:用户点登录后看到的二维码/链接打不开。
- **排查**:登录会话本身正常发起(二维码已生成、轮询端点存在)。
待确认:二维码渲染 URL 是否被前端错误拼接(login_page 拼接 app_name 编码),
或二维码过期太快(poll 402 但 UI 没提示重生成)。
- **状态**:⬜(需要用户复现一次,抓前端渲染的 URL)
## P9 死模型记录基础设施未挂接(P6 的根因之一)
- `isModelGone` / `recordDeadModel` 只有测试 probe 在调;`plugin.hooks = { request_end }` 的
`on_request_end` 只处理 term_error(账号级),不记模型级死亡。
- **修法**:on_request_end 失败分支里,取 payload.model + error message,
`isModelGone(msg)` 为真时 `recordDeadModel(provider, model, msg)` 并重同步源。
- **状态**:⬜
## 部署注意事项
- 工作树有未提交改动:buddy.lua / trae.lua(tool_calls 空 {} 过滤)、provider.go(stop→tool_calls 归一)、
channels.lua(qoder 裸 token + UI 竞态)、provider_test.go、channels_qoder_claim_test.go。
- 生产 channels.lua 哈希与仓库不同步是常态(deploy.sh 会 sync_plugins)。
- 商汤账号:用户需在二维码有效期内扫码确认;当前账号 5a2ea34df521 无凭据属正常(未完成扫码)。

View File

@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@ -220,3 +221,75 @@ func TestQoderRefreshUsesUTC8NotLocalTime(t *testing.T) {
})
}
}
// 裸 token 凭据必须能领:qoder 是 PKCE 设备码流且无 register 第二跳,
// complete_login 存的就是裸 access_token 字符串(真实生产事故:账号池凭据是
// dt-… 裸串,claim 报「缺少凭据对象」,而 account_test 用 token 路径照常通过
// —— 自相矛盾的状态)。活动接口只要 Bearer token,就地包一层即可。
func TestQoderClaimAcceptsBareTokenCredential(t *testing.T) {
var auth string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
auth = r.Header.Get("Authorization")
switch {
case r.URL.Path == "/sash/api/v1/me/campaigns":
fmt.Fprint(w, `{"showCampaign":true,"claimable":true,"campaigns":[
{"campaignId":"C1","actionType":"CLAIM_BENEFIT","claimStatus":"CLAIMABLE","benefit":{"amount":100}}]}`)
case len(r.URL.Path) > len("/sash/api/v1/me/campaigns/") &&
r.URL.Path[len(r.URL.Path)-len("/claim"):] == "/claim":
fmt.Fprint(w, `{"status":"CLAIMED","amount":100}`)
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "qoder",
"qoder_sash_base": map[string]interface{}{"url": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 与生产一致:凭据是**裸 token 字符串**(不是 JSON 对象)。
body, _ := json.Marshal(map[string]string{
"platform": "qoder", "credential": "dt-D6t0cFLOY3fpSmu2Mck2WWeO", "account": "t",
})
if _, err := ps.InvokeHandler("channels", "complete_login", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: string(body),
}); err != nil {
t.Fatal(err)
}
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
Method: "POST", Role: "admin", Body: `{"platform":"qoder"}`,
})
if err != nil {
t.Fatalf("claim_daily: %v", err)
}
j, _ := out.JSON.(map[string]interface{})
oc, _ := j["outcome"].(map[string]interface{})
if oc == nil {
t.Fatalf("应返回 outcome,实得 %v", j)
}
// claim_daily 的通用路径把 claim_fn 的 kind=claimed 归一成 success + message;
// 判据是**不再是 failed** 且消息带领取金额。
kind, _ := oc["kind"].(string)
if kind == "failed" {
t.Fatalf("裸 token 凭据应能领取,实得 kind=failed(%v)", oc["message"])
}
if msg, _ := oc["message"].(string); !strings.Contains(msg, "100") {
t.Errorf("领取成功消息应带金额,实得 %q", msg)
}
if auth != "Bearer dt-D6t0cFLOY3fpSmu2Mck2WWeO" {
t.Errorf("活动接口必须带 Bearer 裸 token,实得 %q", auth)
}
}

View File

@ -840,9 +840,23 @@ func (c *Core) Reload() error {
// ---- adapter management (web UI) ----
func (c *Core) ListAdapters() []lua.APIAdapter {
c.syncHiddenRefs()
return c.vm.ListAdapters()
}
// syncHiddenRefs 把"当前被源引用的适配器"同步给 VM。被引用的 hidden=true 适配器
// 必须保持可见(源编辑器要回显当前值);没被引用的隐藏适配器从列表消失。
// 每次 ListAdapters 前调用:源集合是动态的(运行时可增删),推模型容易漏。
func (c *Core) syncHiddenRefs() {
refs := []string{}
for _, s := range c.mergedSources() {
if s.Adapter != "" {
refs = append(refs, s.Adapter)
}
}
c.vm.SetHiddenRefs(refs)
}
func (c *Core) UploadAdapter(name, code string) error {
if name == "" {
return fmt.Errorf("adapter name required")

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)

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
@ -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)

View File

@ -93,6 +93,12 @@ type staticInfo struct {
// but once an adapter claims the endpoint, protocol iteration lives in the
// adapter, not the kernel. Empty means the adapter claims no ingress.
ingress string
// hidden marks an adapter that should NOT appear in the user-facing adapter
// list (GET /api/adapters → UI 的适配器页与源编辑器的适配器下拉)。隐藏的
// 适配器仍然正常加载、仍然可以被源引用——它只是不从选择列表里冒出来。
// 用途:厂商探针类/内部实验类适配器(如 opencodezen 的免费层门禁形态),
// 直接暴露会让运维误当成通用 OpenAI 适配器去选。声明方式:adapter.hidden = true。
hidden bool
}
// adapterPool owns the worker states for one adapter. Idle workers are held in
@ -596,6 +602,11 @@ type VM struct {
dir string
pools map[string]*adapterPool
// hiddenRefs 记录"被源引用的隐藏适配器"。hidden=true 的适配器默认不进
// ListAdapters,但被源引用时必须保持可见(源编辑器要能回显当前值)。
// 由内核在配置装载/源变更时调用 SetHiddenRefs 维护;nil = 未标记。
hiddenRefs map[string]bool
// janitor drives elastic shrinking. One goroutine serves every pool (not
// one per adapter), so an idle gateway costs a single sleeping goroutine.
janitorOnce sync.Once
@ -603,6 +614,21 @@ type VM struct {
janitorDone chan struct{}
}
// SetHiddenRefs 供内核同步"哪些隐藏适配器正被源引用"。传 nil/空集 = 没有引用。
func (v *VM) SetHiddenRefs(names []string) {
v.mu.Lock()
defer v.mu.Unlock()
if len(names) == 0 {
v.hiddenRefs = nil
return
}
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
v.hiddenRefs = m
}
func NewVM(dir string) *VM {
return &VM{
dir: dir,
@ -799,14 +825,32 @@ func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
func (v *VM) ListAdapters() []APIAdapter {
v.mu.RLock()
defer v.mu.RUnlock()
// hidden=true 的适配器不进用户可见列表(仍正常加载、仍可被源引用)。
// 例外:**正在被某个源引用**的隐藏适配器必须仍出现在列表里——否则源编辑器
// 打开时找不到当前值,下拉会被重置成空,造成"保存后适配器悄悄丢了"。
// 引用关系由内核持有(core 在配置装载时标记),VM 只看这个位。
keepHidden := v.hiddenKeep()
list := make([]APIAdapter, 0, len(v.pools))
for name, p := range v.pools {
if p.static.hidden && !keepHidden[name] {
continue
}
list = append(list, APIAdapter{Name: name, Version: p.static.version})
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
return list
}
// hiddenKeep returns the set of hidden adapter names that must STAY visible
// because at least one source references them. nil 表示内核未标记(测试环境/
// 未装载配置),此时隐藏适配器一律隐藏——与"新适配器默认隐藏"的预期一致。
func (v *VM) hiddenKeep() map[string]bool {
if v.hiddenRefs == nil {
return nil
}
return v.hiddenRefs
}
// AdapterForIngress returns the registered name of the adapter that CLAIMS the
// given client-facing endpoint path (its static.ingress), or "" when none does.
//
@ -1255,6 +1299,10 @@ func inspectScript(name, code string) (*staticInfo, error) {
info.endpoint = readString(L, -1, "endpoint")
info.endpointStream = readString(L, -1, "endpoint_stream")
info.ingress = readString(L, -1, "ingress")
// hidden:适配器可声明 adapter.hidden = true 退出用户可见列表(仍可被源引用)。
L.GetField(-1, "hidden")
info.hidden = L.ToBoolean(-1)
L.Pop(1)
if getFieldBatch(L, "headers") {
luaTableToMap(L, -1, info.headers)
L.Pop(1)

View File

@ -0,0 +1,49 @@
package lua
import "testing"
// hidden=true 的适配器:不被任何源引用时从 ListAdapters 消失;被引用时保持可见。
// 这个行为支撑"内部适配器默认隐藏"的约定(opencodezen 是第一个使用者)。
func TestAdapterHiddenFlag(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
const src = `
local adapter = {}
adapter.name = "secretone"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.hidden = true
function adapter.transform_request(raw) return raw end
function adapter.transform_response(raw) return raw end
return adapter
`
if err := vm.LoadAdapterSource("secretone", src); err != nil {
t.Fatal(err)
}
// 未被引用 → 隐藏
vm.SetHiddenRefs(nil)
for _, a := range vm.ListAdapters() {
if a.Name == "secretone" {
t.Fatal("hidden adapter must not be listed when unreferenced")
}
}
// 被引用 → 可见
vm.SetHiddenRefs([]string{"secretone"})
found := false
for _, a := range vm.ListAdapters() {
if a.Name == "secretone" {
found = true
}
}
if !found {
t.Fatal("hidden adapter must stay listed while referenced by a source")
}
// transform 路径不受 hidden 影响:隐藏 ≠ 禁用
out, err := vm.Transform("secretone", "transform_response", `{"x":1}`)
if err != nil || out == "" {
t.Fatalf("hidden adapter must still transform: %v %q", err, out)
}
}

View File

@ -1260,6 +1260,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
var realChunks int
var doneSeen bool
var doneSent bool
var sawToolCalls bool
// toolIdx maps a per-stream tool fragment's upstream index (Anthropic
// content-block ordinal, which is sparse when thinking/text blocks
// precede tool_use) to a dense 0-based OpenAI tool_call ordinal so
@ -1303,6 +1304,19 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue
}
// A few OpenAI-compatible upstreams emit a final `stop` marker
// after streaming tool-call fragments. Once a real fragment was
// observed, the gateway must preserve tool-call semantics for the
// agent instead of making it treat the turn as ordinary text.
if len(ck.ToolCalls) > 0 {
trimmed := strings.TrimSpace(string(ck.ToolCalls))
if trimmed != "null" && trimmed != "[]" && trimmed != "{}" {
sawToolCalls = true
}
}
if sawToolCalls && ck.FinishReason == "stop" {
ck.FinishReason = "tool_calls"
}
ck.ToolCalls = toolIdx.remap(ck.ToolCalls)
if ck.Done {
doneSent = true

View File

@ -93,7 +93,39 @@ func TestProviderChatStream(t *testing.T) {
}
}
// TestStreamSuccessClearsBackoff guards P6: a clean streaming end must reset
func TestProviderChatStreamNormalizesStopAfterToolCall(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"f"}}]}}]}`+"\n\n")
fmt.Fprint(w, "data: "+`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]},"finish_reason":"stop"}]}`+"\n\n")
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
Model: "m", Stream: true,
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
})
if err != nil {
t.Fatalf("stream: %v", err)
}
var sawTool, sawToolFinish bool
for ck := range ch {
if len(ck.ToolCalls) > 0 && string(ck.ToolCalls) != "{}" {
sawTool = true
}
if ck.FinishReason == "tool_calls" {
sawToolFinish = true
}
}
if !sawTool {
t.Fatal("stream dropped the tool-call fragment")
}
if !sawToolFinish {
t.Fatal("stop after a tool call was not normalized to tool_calls")
}
}
// a previously cooled (source, model) pair.
func TestStreamSuccessClearsBackoff(t *testing.T) {
var fail atomic.Bool