mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
86
docs/channel-issues-2026-10-19.md
Normal file
86
docs/channel-issues-2026-10-19.md
Normal 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 无凭据属正常(未完成扫码)。
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
49
internal/lua/vm_hidden_test.go
Normal file
49
internal/lua/vm_hidden_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user