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

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