From 711ccdeea8ebe6c692b35f459c134fc64d6ebef1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 19 Oct 2026 22:32:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E5=9B=9B=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=E2=80=94=20qoder=20=E8=A3=B8=20t?= =?UTF-8?q?oken=20=E9=A2=86=E5=8F=96=20/=20trae+buddy=20=E9=9D=9E=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=20/=20opencode=20=E7=9B=AE=E5=BD=95=20/=20=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测逐渠道排查(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 2113bfc125e9cab60595d18af65ab29675e7caef) --- docs/channel-issues-2026-10-19.md | 86 ++++++++++++++ internal/core/channels_qoder_claim_test.go | 73 ++++++++++++ internal/core/core.go | 14 +++ internal/lua/adapters/buddy.lua | 101 +++++++++++++++- internal/lua/adapters/opencodezen.lua | 58 +++++++++- internal/lua/adapters/trae.lua | 2 +- internal/lua/plugins/channels.lua | 127 +++++++++++++++++++-- internal/lua/vm.go | 48 ++++++++ internal/lua/vm_hidden_test.go | 49 ++++++++ internal/provider/provider.go | 14 +++ internal/provider/provider_test.go | 34 +++++- 11 files changed, 585 insertions(+), 21 deletions(-) create mode 100644 docs/channel-issues-2026-10-19.md create mode 100644 internal/lua/vm_hidden_test.go diff --git a/docs/channel-issues-2026-10-19.md b/docs/channel-issues-2026-10-19.md new file mode 100644 index 0000000..23cfcd0 --- /dev/null +++ b/docs/channel-issues-2026-10-19.md @@ -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 无凭据属正常(未完成扫码)。 diff --git a/internal/core/channels_qoder_claim_test.go b/internal/core/channels_qoder_claim_test.go index d35b1f8..583475c 100644 --- a/internal/core/channels_qoder_claim_test.go +++ b/internal/core/channels_qoder_claim_test.go @@ -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) + } +} diff --git a/internal/core/core.go b/internal/core/core.go index 5eb1baa..8d36661 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -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") diff --git a/internal/lua/adapters/buddy.lua b/internal/lua/adapters/buddy.lua index 58b4e51..d2a624f 100644 --- a/internal/lua/adapters/buddy.lua +++ b/internal/lua/adapters/buddy.lua @@ -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) diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua index cd4b68f..bb74c8a 100644 --- a/internal/lua/adapters/opencodezen.lua +++ b/internal/lua/adapters/opencodezen.lua @@ -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", diff --git a/internal/lua/adapters/trae.lua b/internal/lua/adapters/trae.lua index 9a60006..e9759ca 100644 --- a/internal/lua/adapters/trae.lua +++ b/internal/lua/adapters/trae.lua @@ -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) diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index ce25179..27d1327 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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 = '

加载中…

'; 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) diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 3dfb7fe..01c770a 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -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) diff --git a/internal/lua/vm_hidden_test.go b/internal/lua/vm_hidden_test.go new file mode 100644 index 0000000..c8f1d20 --- /dev/null +++ b/internal/lua/vm_hidden_test.go @@ -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) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e4c8ae7..71cde9a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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 diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index ca4f204..a9a4595 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -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