From b0e89a4dd7a2ee158a79f80fb409c354140c7778 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Mon, 24 Aug 2026 14:29:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(responses):=20=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=20OpenRouter=20=E9=9D=9E=E6=A0=87=E5=87=86=20reasonin?= =?UTF-8?q?g=20=E5=BD=A2=E6=80=81=E4=B8=BA=20OpenAI=20=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter 的 Responses API 把思考文本放在非标准 content[reasoning_text] (summary 留空),流式发 response.reasoning_text.* 事件而非规范的 response.reasoning_summary_*,按规范实现的客户端(Codex CLI 等)看不到思考过程。 新增 converters 三个形状触发的归一化入口(标准上游零改动、幂等),接入 Responses 透传流式主路 runResponsesFold、兜底逐行中继 processLine 与 非流式 ConvertToResponsesResponse;content_index 同步迁移为 summary_index。 --- CHANGELOG.md | 1 + .../responses_reasoning_normalize.go | 143 +++++++++++ .../responses_reasoning_normalize_test.go | 238 ++++++++++++++++++ .../internal/handlers/responses/fold.go | 4 + .../fold_openrouter_reasoning_test.go | 138 ++++++++++ .../internal/handlers/responses/stream.go | 2 +- backend-go/internal/providers/responses.go | 2 + .../responses_openrouter_reasoning_test.go | 57 +++++ 8 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 backend-go/internal/converters/responses_reasoning_normalize.go create mode 100644 backend-go/internal/converters/responses_reasoning_normalize_test.go create mode 100644 backend-go/internal/handlers/responses/fold_openrouter_reasoning_test.go create mode 100644 backend-go/internal/providers/responses_openrouter_reasoning_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1df5a2..c1f9d18f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - **模型清单展示改进** - 共有/协议专属模型少于 10 个时直接展开列出,其余默认两行、仅实际溢出时显示展开 - **火山套餐 Key 保活 per-key 路由与共享探针** - `config.BaseURLsForKey` 让已绑定端点的 Key 只在自己端点探测,不参与渠道级 BaseURL 笛卡尔积;新增 `internal/upstreamprobe` 共享火山 Agent/Coding Plan 数据面探针(autopilot 验证与 healthcheck 保活共用,避免请求特征漂移);`L1Response.RealCallVerified` 标记真实调用,火山 L1 成功后同周期跳过等价 L2 避免重复消耗额度;L2 探针副本覆盖绑定 BaseURL,recordFailure 归因到 Key 实际绑定端点 - **渠道级实测上下文上限自学习** - 模型注册表登记的是模型公开窗口,个别渠道对某个模型的实际窗口更短(中转商自行截断、上游按套餐限制),长上下文请求会反复吃 400 `context_too_large`。新增按 渠道-Key-模型 维度记忆实测上限的数值型能力覆盖(`config.ContextLimitState`,复用 `ChannelCompatCache` 键空间/24h TTL/落盘,但独立于布尔型 `CompatTrait` 存储):`ContextLimitFromError` 从 400/422 报错识别超限信号,上游明确声明窗口值时直接采信,仅表示「太长」时按被拒请求量的 7/8 反推保守上界,并排除 `max_tokens`/请求体过大/图片尺寸/配额等相邻错误避免误判;合成规则「宁小勿大」多次学习取最小值,只在遇到更严格证据时收紧,放宽只能靠 TTL 过期重学;`SmartRouter.buildChannelEntry` 取 `min(注册表窗口, 实测上限)` 让上下文硬约束按真实容量判断,路由发生在选定 Key 之前故取该渠道-模型在所有已知 Key 上的最小值。上下文上限按自身 `LearnedAt` 独立计算 TTL(不共用 `entry.DetectedAt`,否则 trait 命中刷新会让实测上限被无限续期);无记忆时 fail-open,反推上界低于 4096 不采信。兼容性记忆单例上移为 `config.SharedChannelCompatCache`,供写入方 handlers 与读取方 autopilot 共享 +- **OpenRouter Responses 思考内容标准化** - OpenRouter 的 Responses API 实现与 OpenAI 规范存在系统性偏差:非流式 reasoning 条目把思考文本放在非标准 `content[]`(part 类型 `reasoning_text`)而规范位置 `summary[]` 留空;流式思考增量用非标准事件 `response.reasoning_text.delta/.done`、content_part 事件携带 `reasoning_text` part,而非规范的 `response.reasoning_summary_text.*` / `response.reasoning_summary_part.*` 与 `summary_text` part(分片序号也复用了 `content_index` 而非 `summary_index`)。按 OpenAI 规范实现的客户端(Codex CLI 等)只读 summary/summary 系事件,导致经 OpenRouter 中转的推理模型思考过程不可见。新增 `converters.NormalizeOpenRouterReasoningEvent/NormalizeOpenRouterReasoningResponseBody/NormalizeOpenRouterReasoningSSELine` 把非标准形态归一化为规范形态,全部按数据形状触发:仅事件名精确命中、或 summary 为空且 content 全为 reasoning_text 时才改写,标准上游零改动且幂等。接入三处:Responses 透传流式主路径 `runResponsesFold` 在逐事件解析后统一归一化(顺带让 preflight 语义判断与 thinkingcache 收集对 OpenRouter 流生效),兜底逐行中继路径 `processLine` 按行归一化(仅含 reasoning 字样的行才解析 JSON),非流式 `ConvertToResponsesResponse` 在类型化转换前归一化响应体;转 Claude 协议的 `HandleStreamResponse` 原本已双兼容两种事件名无需改动 ### 修复 diff --git a/backend-go/internal/converters/responses_reasoning_normalize.go b/backend-go/internal/converters/responses_reasoning_normalize.go new file mode 100644 index 00000000..ec875da0 --- /dev/null +++ b/backend-go/internal/converters/responses_reasoning_normalize.go @@ -0,0 +1,143 @@ +package converters + +import ( + "encoding/json" + "strings" +) + +// OpenRouter 的 Responses API 实现与 OpenAI 规范存在两处系统性偏差: +// 1. 非流式:reasoning 输出条目把思考文本放在非标准 content[](part 类型 reasoning_text)里, +// 而规范位置 summary[] 留空; +// 2. 流式:思考增量用非标准事件 response.reasoning_text.delta/.done, +// 且 content_part.added/.done 携带 reasoning_text part, +// 而非规范的 response.reasoning_summary_text.* / response.reasoning_summary_part.* 与 summary_text part。 +// +// 按 OpenAI 规范实现的客户端(Codex CLI 等)只读 summary / summary 系事件, +// 导致经 OpenRouter 中转的推理模型思考内容不可见。本文件将上述形态归一化为规范形态。 +// +// 所有改写均按数据形状触发:仅当事件名精确命中或 summary 为空且 content 全部为 +// reasoning_text 时才改写,标准上游的响应不受影响,重复调用幂等。 + +// openRouterReasoningEventRenames 需要改名的非标准流式事件 -> 规范事件名。 +var openRouterReasoningEventRenames = map[string]string{ + "response.reasoning_text.delta": "response.reasoning_summary_text.delta", + "response.reasoning_text.done": "response.reasoning_summary_text.done", +} + +// NormalizeOpenRouterReasoningEvent 就地归一化单条 Responses SSE 事件, +// 返回是否发生改写。标准事件原样保留。 +func NormalizeOpenRouterReasoningEvent(event map[string]interface{}) bool { + eventType, _ := event["type"].(string) + if eventType == "" { + return false + } + + switch eventType { + case "response.output_item.added", "response.output_item.done": + item, _ := event["item"].(map[string]interface{}) + if item == nil { + return false + } + return normalizeResponsesReasoningItem(item) + case "response.content_part.added", "response.content_part.done": + part, ok := event["part"].(map[string]interface{}) + if !ok { + return false + } + if partType, _ := part["type"].(string); partType != "reasoning_text" { + return false + } + suffix := strings.TrimPrefix(eventType, "response.content_part.") + part["type"] = "summary_text" + event["type"] = "response.reasoning_summary_part." + suffix + renameContentIndexToSummaryIndex(event) + return true + default: + renamed, ok := openRouterReasoningEventRenames[eventType] + if !ok { + return false + } + event["type"] = renamed + renameContentIndexToSummaryIndex(event) + return true + } +} + +// NormalizeOpenRouterReasoningResponseBody 就地归一化 Responses 非流式响应体中 +// reasoning 条目的非标准 content 形态,返回是否发生改写。 +func NormalizeOpenRouterReasoningResponseBody(body map[string]interface{}) bool { + output, ok := body["output"].([]interface{}) + if !ok { + return false + } + changed := false + for _, rawItem := range output { + if item, ok := rawItem.(map[string]interface{}); ok && normalizeResponsesReasoningItem(item) { + changed = true + } + } + return changed +} + +// NormalizeOpenRouterReasoningSSELine 对透传中继路径的单行 SSE 做归一化。 +// 仅 data 行且负载含 reasoning 字样时才解析 JSON,其余行原样返回以保持零开销。 +func NormalizeOpenRouterReasoningSSELine(line string) string { + payload, ok := strings.CutPrefix(strings.TrimSpace(line), "data:") + if !ok || !strings.Contains(payload, "reasoning") { + return line + } + var event map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimSpace(payload)), &event); err != nil { + return line + } + if !NormalizeOpenRouterReasoningEvent(event) { + return line + } + normalized, err := json.Marshal(event) + if err != nil { + return line + } + return "data: " + string(normalized) +} + +// normalizeResponsesReasoningItem 将 reasoning 条目中非标准的 content[reasoning_text] +// 迁移为规范的 summary[summary_text],返回是否发生改写。 +// summary 已有内容、content 含未知 part 类型时保守跳过,避免破坏异构上游的自定义扩展。 +func normalizeResponsesReasoningItem(item map[string]interface{}) bool { + if itemType, _ := item["type"].(string); itemType != "reasoning" { + return false + } + if summary, ok := item["summary"].([]interface{}); ok && len(summary) > 0 { + return false + } + content, ok := item["content"].([]interface{}) + if !ok || len(content) == 0 { + return false + } + parts := make([]interface{}, 0, len(content)) + for _, rawPart := range content { + part, ok := rawPart.(map[string]interface{}) + if !ok { + return false + } + if partType, _ := part["type"].(string); partType != "reasoning_text" { + return false + } + parts = append(parts, map[string]interface{}{"type": "summary_text", "text": part["text"]}) + } + item["summary"] = parts + delete(item, "content") + return true +} + +// renameContentIndexToSummaryIndex 规范的 summary 系事件用 summary_index 标记分片序号, +// OpenRouter 复用了 content_index;语义相同,改名即可。 +func renameContentIndexToSummaryIndex(event map[string]interface{}) { + if _, has := event["summary_index"]; has { + return + } + if idx, ok := event["content_index"]; ok { + event["summary_index"] = idx + delete(event, "content_index") + } +} diff --git a/backend-go/internal/converters/responses_reasoning_normalize_test.go b/backend-go/internal/converters/responses_reasoning_normalize_test.go new file mode 100644 index 00000000..cc8d3df8 --- /dev/null +++ b/backend-go/internal/converters/responses_reasoning_normalize_test.go @@ -0,0 +1,238 @@ +package converters + +import ( + "encoding/json" + "strings" + "testing" +) + +func mustEvent(t *testing.T, raw string) map[string]interface{} { + t.Helper() + var event map[string]interface{} + if err := json.Unmarshal([]byte(raw), &event); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + return event +} + +func TestNormalizeOpenRouterReasoningEvent(t *testing.T) { + tests := []struct { + name string + in string + wantChanged bool + wantType string + wantSummary interface{} + }{ + { + name: "reasoning_text_delta 改名为 summary_text 并迁移索引", + in: `{"type":"response.reasoning_text.delta","item_id":"rs_1","output_index":0,"content_index":0,"delta":"思考"}`, + wantChanged: true, + wantType: "response.reasoning_summary_text.delta", + wantSummary: nil, + }, + { + name: "reasoning_text_done 改名", + in: `{"type":"response.reasoning_text.done","item_id":"rs_1","text":"思考完成"}`, + wantChanged: true, + wantType: "response.reasoning_summary_text.done", + }, + { + name: "content_part_added 的 reasoning_text part 转为 summary part", + in: `{"type":"response.content_part.added","item_id":"rs_1","output_index":0,"content_index":0,"part":{"type":"reasoning_text","text":""}}`, + wantChanged: true, + wantType: "response.reasoning_summary_part.added", + }, + { + name: "content_part_done 携带完整文本", + in: `{"type":"response.content_part.done","part":{"type":"reasoning_text","text":"完整思考"}}`, + wantChanged: true, + wantType: "response.reasoning_summary_part.done", + }, + { + name: "标准 summary 事件不动", + in: `{"type":"response.reasoning_summary_text.delta","summary_index":0,"delta":"x"}`, + wantChanged: false, + wantType: "response.reasoning_summary_text.delta", + }, + { + name: "output_text 事件不动", + in: `{"type":"response.output_text.delta","delta":"hi"}`, + wantChanged: false, + wantType: "response.output_text.delta", + }, + { + name: "无 type 的事件不动", + in: `{"foo":"bar"}`, + wantChanged: false, + wantType: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := mustEvent(t, tt.in) + changed := NormalizeOpenRouterReasoningEvent(event) + if changed != tt.wantChanged { + t.Fatalf("changed = %v, want %v", changed, tt.wantChanged) + } + if got, _ := event["type"].(string); got != tt.wantType { + t.Fatalf("type = %v, want %v", got, tt.wantType) + } + if _, has := event["content_index"]; has && changed { + t.Fatalf("content_index should be renamed to summary_index: %#v", event) + } + }) + } +} + +func TestNormalizeOpenRouterReasoningEventOutputItem(t *testing.T) { + event := mustEvent(t, `{ + "type": "response.output_item.done", + "item": { + "id": "rs_1", + "type": "reasoning", + "status": "completed", + "summary": [], + "content": [{"type": "reasoning_text", "text": "17*20=340"}] + } + }`) + if !NormalizeOpenRouterReasoningEvent(event) { + t.Fatal("expected change") + } + item := event["item"].(map[string]interface{}) + if _, has := item["content"]; has { + t.Fatalf("content field should be removed: %#v", item) + } + summary, ok := item["summary"].([]interface{}) + if !ok || len(summary) != 1 { + t.Fatalf("summary = %#v, want one part", item["summary"]) + } + part := summary[0].(map[string]interface{}) + if part["type"] != "summary_text" || part["text"] != "17*20=340" { + t.Fatalf("summary part = %#v", part) + } + + // 幂等:再次调用不再改动 + if NormalizeOpenRouterReasoningEvent(event) { + t.Fatal("second call should be a no-op") + } +} + +func TestNormalizeOpenRouterReasoningEventKeepsStandardItem(t *testing.T) { + event := mustEvent(t, `{ + "type": "response.output_item.done", + "item": { + "id": "rs_2", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "官方形态"}] + } + }`) + if NormalizeOpenRouterReasoningEvent(event) { + t.Fatal("standard item should not be touched") + } +} + +func TestNormalizeOpenRouterReasoningEventSkipsUnknownContentParts(t *testing.T) { + event := mustEvent(t, `{ + "type": "response.output_item.done", + "item": { + "id": "rs_3", + "type": "reasoning", + "summary": [], + "content": [{"type": "reasoning_text", "text": "a"}, {"type": "custom", "text": "b"}] + } + }`) + if NormalizeOpenRouterReasoningEvent(event) { + t.Fatal("unknown content part types should be left untouched") + } +} + +func TestNormalizeOpenRouterReasoningResponseBody(t *testing.T) { + body := map[string]interface{}{ + "id": "resp_1", + "output": []interface{}{ + map[string]interface{}{ + "id": "rs_1", + "type": "reasoning", + "summary": []interface{}{}, + "content": []interface{}{map[string]interface{}{"type": "reasoning_text", "text": "思考"}}, + }, + map[string]interface{}{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": []interface{}{map[string]interface{}{"type": "output_text", "text": "391"}}, + }, + }, + } + if !NormalizeOpenRouterReasoningResponseBody(body) { + t.Fatal("expected change") + } + reasoning := body["output"].([]interface{})[0].(map[string]interface{}) + if _, has := reasoning["content"]; has { + t.Fatalf("reasoning content should be removed: %#v", reasoning) + } + message := body["output"].([]interface{})[1].(map[string]interface{}) + if _, has := message["content"]; !has { + t.Fatal("message content must be preserved") + } + + // 标准响应体零改动 + standard := map[string]interface{}{ + "output": []interface{}{ + map[string]interface{}{ + "id": "rs_2", + "type": "reasoning", + "summary": []interface{}{map[string]interface{}{"type": "summary_text", "text": "官方"}}, + }, + }, + } + if NormalizeOpenRouterReasoningResponseBody(standard) { + t.Fatal("standard body should not be touched") + } +} + +func TestNormalizeOpenRouterReasoningSSELine(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "非 data 行原样返回", + in: "event: response.created", + want: "event: response.created", + }, + { + name: "DONE 行原样返回", + in: "data: [DONE]", + want: "data: [DONE]", + }, + { + name: "不含 reasoning 的行不解析", + in: `data: {"type":"response.output_text.delta","delta":"hi"}`, + want: `data: {"type":"response.output_text.delta","delta":"hi"}`, + }, + { + name: "畸形 JSON 原样返回", + in: `data: {"type":"response.reasoning_text.delta","delta":`, + want: `data: {"type":"response.reasoning_text.delta","delta":`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeOpenRouterReasoningSSELine(tt.in); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } + + line := `data:{"type":"response.reasoning_text.delta","content_index":0,"delta":"想"}` + got := NormalizeOpenRouterReasoningSSELine(line) + if !strings.HasPrefix(got, "data: ") || !strings.Contains(got, "reasoning_summary_text.delta") { + t.Fatalf("unexpected normalization: %q", got) + } + if strings.Contains(got, "content_index") { + t.Fatalf("content_index should be renamed: %q", got) + } +} diff --git a/backend-go/internal/handlers/responses/fold.go b/backend-go/internal/handlers/responses/fold.go index 4be02f19..9a6a564c 100644 --- a/backend-go/internal/handlers/responses/fold.go +++ b/backend-go/internal/handlers/responses/fold.go @@ -12,6 +12,7 @@ import ( "time" "github.com/BenedictKing/ccx/internal/config" + "github.com/BenedictKing/ccx/internal/converters" "github.com/BenedictKing/ccx/internal/handlers/common" "github.com/BenedictKing/ccx/internal/providers" "github.com/BenedictKing/ccx/internal/session" @@ -153,6 +154,9 @@ func runResponsesFold( continue } + // OpenRouter 等上游的 reasoning 非标准事件/条目归一化(按形状触发,标准流零改动) + converters.NormalizeOpenRouterReasoningEvent(frame.event) + eventType, _ := frame.event["type"].(string) switch eventType { case "response.created", "response.in_progress": diff --git a/backend-go/internal/handlers/responses/fold_openrouter_reasoning_test.go b/backend-go/internal/handlers/responses/fold_openrouter_reasoning_test.go new file mode 100644 index 00000000..6c761bb6 --- /dev/null +++ b/backend-go/internal/handlers/responses/fold_openrouter_reasoning_test.go @@ -0,0 +1,138 @@ +package responses + +import ( + "encoding/json" + "strings" + "testing" +) + +// OpenRouter 流式 reasoning 非标准形态(reasoning_text.* 事件 + content[reasoning_text] 条目) +// 经 runResponsesFold 归一化后,应全部转为规范的 summary 系事件与 summary 条目。 +func TestRunResponsesFoldNormalizesOpenRouterReasoningEvents(t *testing.T) { + upstreamSSE := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_1","status":"in_progress","output":[]}}`, + `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","status":"in_progress","summary":[]}}`, + `data: {"type":"response.content_part.added","item_id":"rs_1","output_index":0,"content_index":0,"part":{"type":"reasoning_text","text":""}}`, + `data: {"type":"response.reasoning_text.delta","item_id":"rs_1","output_index":0,"content_index":0,"delta":"17*"}`, + `data: {"type":"response.reasoning_text.delta","item_id":"rs_1","output_index":0,"content_index":0,"delta":"23=391"}`, + `data: {"type":"response.reasoning_text.done","item_id":"rs_1","text":"17*23=391"}`, + `data: {"type":"response.content_part.done","item_id":"rs_1","part":{"type":"reasoning_text","text":"17*23=391"}}`, + `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","status":"completed","summary":[],"content":[{"type":"reasoning_text","text":"17*23=391"}],"encrypted_content":"ENC_1"}}`, + `data: {"type":"response.output_item.added","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant"}}`, + `data: {"type":"response.output_text.delta","output_index":1,"item_id":"msg_1","content_index":0,"delta":"**391**"}`, + `data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"**391**"}]}}`, + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}`, + "", + }, "\n\n") + + var emitted []map[string]interface{} + _, err := runResponsesFold( + map[string]interface{}{"model": "test", "stream": true, "input": []interface{}{}}, + responsesFoldTestResp(upstreamSSE), + nil, + func(event map[string]interface{}) error { + emitted = append(emitted, event) + return nil + }, + ) + if err != nil { + t.Fatalf("runResponsesFold() err = %v", err) + } + + types := make([]string, 0, len(emitted)) + byType := map[string][]map[string]interface{}{} + for _, event := range emitted { + eventType, _ := event["type"].(string) + types = append(types, eventType) + byType[eventType] = append(byType[eventType], event) + } + joined := strings.Join(types, ",") + + for _, want := range []string{ + "response.reasoning_summary_part.added", + "response.reasoning_summary_text.delta", + "response.reasoning_summary_text.done", + "response.reasoning_summary_part.done", + } { + if !strings.Contains(joined, want) { + t.Fatalf("missing normalized event %q in: %s", want, joined) + } + } + if strings.Contains(joined, "reasoning_text.") { + t.Fatalf("non-standard reasoning_text events leaked: %s", joined) + } + + // delta 的分片索引应为 summary_index + delta := byType["response.reasoning_summary_text.delta"][0] + if _, has := delta["summary_index"]; !has { + t.Fatalf("delta missing summary_index: %#v", delta) + } + + // output_item.done 的 reasoning 条目应已迁移为 summary 形态且保留 encrypted_content + doneItems := byType["response.output_item.done"] + var reasoningItem map[string]interface{} + for _, event := range doneItems { + item := event["item"].(map[string]interface{}) + if item["type"] == "reasoning" { + reasoningItem = item + } + } + if reasoningItem == nil { + t.Fatalf("no reasoning item done event: %#v", emitted) + } + if _, has := reasoningItem["content"]; has { + t.Fatalf("reasoning item still has content field: %#v", reasoningItem) + } + summary, ok := reasoningItem["summary"].([]interface{}) + if !ok || len(summary) == 0 || summary[0].(map[string]interface{})["text"] != "17*23=391" { + t.Fatalf("reasoning summary not normalized: %#v", reasoningItem["summary"]) + } + if reasoningItem["encrypted_content"] != "ENC_1" { + t.Fatalf("encrypted_content must be preserved: %#v", reasoningItem) + } + + // 终止事件 response.completed 里回放的 output 也应是规范形态 + terminal := byType["response.completed"][0] + response := terminal["response"].(map[string]interface{}) + output := response["output"].([]interface{}) + first := output[0].(map[string]interface{}) + if first["type"] != "reasoning" { + t.Fatalf("terminal output first item should be reasoning: %#v", first) + } + if _, has := first["content"]; has { + t.Fatalf("terminal output reasoning item not normalized: %#v", first) + } +} + +func TestRunResponsesFoldKeepsStandardSummaryEventsUntouched(t *testing.T) { + upstreamSSE := strings.Join([]string{ + `data: {"type":"response.created","response":{"id":"resp_2","status":"in_progress","output":[]}}`, + `data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_9","output_index":0,"summary_index":0,"delta":"官方形态"}`, + `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"rs_9","type":"reasoning","summary":[{"type":"summary_text","text":"官方形态"}]}}`, + `data: {"type":"response.completed","response":{"id":"resp_2","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, + "", + }, "\n\n") + + var emitted []map[string]interface{} + _, err := runResponsesFold( + map[string]interface{}{"model": "test", "stream": true, "input": []interface{}{}}, + responsesFoldTestResp(upstreamSSE), + nil, + func(event map[string]interface{}) error { + emitted = append(emitted, event) + return nil + }, + ) + if err != nil { + t.Fatalf("runResponsesFold() err = %v", err) + } + raw, _ := json.Marshal(emitted) + if !strings.Contains(string(raw), `"summary_index"`) { + t.Fatalf("standard summary_index should be preserved: %s", raw) + } + item := emitted[2]["item"].(map[string]interface{}) + summary := item["summary"].([]interface{})[0].(map[string]interface{}) + if summary["text"] != "官方形态" || summary["type"] != "summary_text" { + t.Fatalf("standard item was mutated: %#v", item) + } +} diff --git a/backend-go/internal/handlers/responses/stream.go b/backend-go/internal/handlers/responses/stream.go index bbbc569f..aa5e168d 100644 --- a/backend-go/internal/handlers/responses/stream.go +++ b/backend-go/internal/handlers/responses/stream.go @@ -420,7 +420,7 @@ func handleStreamSuccess( } eventsToProcess = events } else { - eventsToProcess = []string{line + "\n"} + eventsToProcess = []string{converters.NormalizeOpenRouterReasoningSSELine(line) + "\n"} } for _, event := range eventsToProcess { diff --git a/backend-go/internal/providers/responses.go b/backend-go/internal/providers/responses.go index 2d442f42..a552d720 100644 --- a/backend-go/internal/providers/responses.go +++ b/backend-go/internal/providers/responses.go @@ -651,6 +651,8 @@ func (p *ResponsesProvider) ConvertToResponsesResponse( if err != nil { return nil, fmt.Errorf("解析响应失败: %w", err) } + // OpenRouter 等上游会把思考文本放在非标准 content 字段,先归一化为规范 summary 形态 + converters.NormalizeOpenRouterReasoningResponseBody(respMap) converter := converters.NewConverter(upstreamType) return converter.FromProviderResponse(respMap, sessionID) } diff --git a/backend-go/internal/providers/responses_openrouter_reasoning_test.go b/backend-go/internal/providers/responses_openrouter_reasoning_test.go new file mode 100644 index 00000000..8f290591 --- /dev/null +++ b/backend-go/internal/providers/responses_openrouter_reasoning_test.go @@ -0,0 +1,57 @@ +package providers + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/BenedictKing/ccx/internal/types" +) + +// OpenRouter 非流式响应把思考文本放在非标准 content[reasoning_text], +// ConvertToResponsesResponse 应先归一化为规范 summary[summary_text] 再透出。 +func TestResponsesProvider_ConvertToResponsesResponse_NormalizesOpenRouterBody(t *testing.T) { + provider := &ResponsesProvider{} + resp, err := provider.ConvertToResponsesResponse(&types.ProviderResponse{ + Body: []byte(`{ + "id": "resp_or_1", + "model": "test-model", + "status": "completed", + "output": [ + { + "id": "rs_1", + "type": "reasoning", + "status": "completed", + "summary": [], + "content": [{"type": "reasoning_text", "text": "17*23=391"}] + }, + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "**391**"}] + } + ], + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15} + }`), + }, "responses", "") + if err != nil { + t.Fatalf("ConvertToResponsesResponse() err = %v", err) + } + + raw, _ := json.Marshal(resp.Output) + if strings.Contains(string(raw), "reasoning_text") { + t.Fatalf("non-standard reasoning_text part leaked: %s", raw) + } + reasoning := resp.Output[0] + if reasoning.Summary == nil { + t.Fatalf("summary should be populated: %#v", reasoning) + } + summaryJSON, _ := json.Marshal(reasoning.Summary) + if !strings.Contains(string(summaryJSON), "17*23=391") || !strings.Contains(string(summaryJSON), "summary_text") { + t.Fatalf("unexpected summary: %s", summaryJSON) + } + if reasoning.Content != nil { + t.Fatalf("content should be removed after normalization: %#v", reasoning.Content) + } +} From b480629e09e13dcdcff6a146a56a2dbee24802b1 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Mon, 24 Aug 2026 15:59:10 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(responses):=20compact=20=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E6=8E=A5=E5=85=A5=20reasoning=20=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E5=B9=B6=E8=A1=A5=E5=85=A8=20knownTypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compact_local.go V1 本地压缩透传分支接入 NormalizeOpenRouterReasoningSSELine: OpenRouter 非标准 reasoning_text.* / content_part(reasoning_text) 归一化后 恰好落入 shouldSkipCompactStreamEvent 现有过滤清单,修复思考增量泄漏进 compact 流(responses 渠道原生 compact 失败回退本地压缩为常态路径) - helpers.go knownTypes 补全标准事件:content_part.added/.done、output_text.done、 output_json.done、refusal.delta/.done、audio(.transcript).done——这些高频标准 事件此前会被误判 unknown,导致空流诊断文案被 seenUnknown 分支抢占失真 (仅影响诊断日志,不影响 commit/failover 判定;v2.9.37 原版既有缺口) - 补充两组回归测试:归一化后过滤行为 + 标准事件名白名单 --- .../handlers/responses/compact_local.go | 4 ++- .../handlers/responses/compact_local_test.go | 32 +++++++++++++++++++ .../handler_stream_preflight_test.go | 27 ++++++++++++++++ .../internal/handlers/responses/helpers.go | 7 ++-- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/backend-go/internal/handlers/responses/compact_local.go b/backend-go/internal/handlers/responses/compact_local.go index 9d70614c..d8ac75aa 100644 --- a/backend-go/internal/handlers/responses/compact_local.go +++ b/backend-go/internal/handlers/responses/compact_local.go @@ -570,7 +570,9 @@ func handleLocalCompactStream( ) } } else { - eventsToSend = []string{line + "\n"} + // OpenRouter 等上游的非标准 reasoning 事件归一化后, + // reasoning_text.* / content_part(reasoning_text) 恰好落入 shouldSkipCompactStreamEvent 的过滤清单 + eventsToSend = []string{converters.NormalizeOpenRouterReasoningSSELine(line) + "\n"} } for _, event := range eventsToSend { diff --git a/backend-go/internal/handlers/responses/compact_local_test.go b/backend-go/internal/handlers/responses/compact_local_test.go index 56c8d00b..f104476c 100644 --- a/backend-go/internal/handlers/responses/compact_local_test.go +++ b/backend-go/internal/handlers/responses/compact_local_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/BenedictKing/ccx/internal/config" + "github.com/BenedictKing/ccx/internal/converters" "github.com/BenedictKing/ccx/internal/metrics" "github.com/BenedictKing/ccx/internal/scheduler" "github.com/BenedictKing/ccx/internal/session" @@ -456,3 +457,34 @@ func TestShouldSkipCompactStreamEvent(t *testing.T) { }) } } + +// 验证 compact 透传分支接入归一化后,OpenRouter 非标准 reasoning 事件 +// 会被改写为规范事件名并落入 shouldSkipCompactStreamEvent 过滤清单。 +func TestCompactPassthroughNormalizesOpenRouterReasoning(t *testing.T) { + openRouterLines := []string{ + `data: {"type":"response.reasoning_text.delta","output_index":0,"content_index":0,"delta":"thinking"}`, + `data: {"type":"response.reasoning_text.done","output_index":0,"content_index":0,"text":"thinking done"}`, + `data: {"type":"response.content_part.added","output_index":0,"item_id":"rs_1","content_index":0,"part":{"type":"reasoning_text","text":""}}`, + `data: {"type":"response.content_part.done","output_index":0,"item_id":"rs_1","content_index":0,"part":{"type":"reasoning_text","text":"thinking done"}}`, + } + for _, line := range openRouterLines { + normalized := converters.NormalizeOpenRouterReasoningSSELine(line) + "\n" + if !shouldSkipCompactStreamEvent(normalized) { + t.Errorf("归一化后的 OpenRouter reasoning 事件应被 compact 流过滤,line: %s", line) + } + } + + passthroughLines := []string{ + `data: {"type":"response.output_text.delta","output_index":1,"content_index":0,"delta":"hello"}`, + `data: {"type":"response.completed","response":{"id":"resp_1","output":[]}}`, + } + for _, line := range passthroughLines { + normalized := converters.NormalizeOpenRouterReasoningSSELine(line) + "\n" + if shouldSkipCompactStreamEvent(normalized) { + t.Errorf("正文/终态事件不应被过滤,line: %s", line) + } + if normalized != line+"\n" { + t.Errorf("标准事件应原样透传,got: %s, want: %s", normalized, line+"\n") + } + } +} diff --git a/backend-go/internal/handlers/responses/handler_stream_preflight_test.go b/backend-go/internal/handlers/responses/handler_stream_preflight_test.go index 97d66b9f..01948f27 100644 --- a/backend-go/internal/handlers/responses/handler_stream_preflight_test.go +++ b/backend-go/internal/handlers/responses/handler_stream_preflight_test.go @@ -229,3 +229,30 @@ func TestFirstUnknownResponsesEventType_AllowsResponsesLifecycleAndErrorTypes(t }) } } + +// 标准 OpenAI 流中高频出现的 content_part 与 done 系事件不应被判为未知类型, +// 否则空流诊断文案会被 seenUnknown 分支抢占(knownTypes 缺口回归测试)。 +func TestFirstUnknownResponsesEventType_AllowsStandardContentPartAndDoneEvents(t *testing.T) { + eventTypes := []string{ + "response.content_part.added", + "response.content_part.delta", + "response.content_part.done", + "response.output_text.delta", + "response.output_text.done", + "response.output_json.done", + "response.refusal.delta", + "response.refusal.done", + "response.audio.done", + "response.audio_transcript.done", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + } + for _, eventType := range eventTypes { + t.Run(eventType, func(t *testing.T) { + event := "data: {\"type\":\"" + eventType + "\",\"output_index\":0}\n" + if got, ok := firstUnknownResponsesEventType(event); ok { + t.Fatalf("firstUnknownResponsesEventType() = %q, true; want known type", got) + } + }) + } +} diff --git a/backend-go/internal/handlers/responses/helpers.go b/backend-go/internal/handlers/responses/helpers.go index 7d0367f5..6a170266 100644 --- a/backend-go/internal/handlers/responses/helpers.go +++ b/backend-go/internal/handlers/responses/helpers.go @@ -235,10 +235,13 @@ func isCompactionV2UsageOnlyStream(isCompactionV2, seenCompleted, seenUsageOnly func firstUnknownResponsesEventType(event string) (string, bool) { knownTypes := map[string]struct{}{ "response.created": {}, "response.in_progress": {}, "response.incomplete": {}, - "response.output_text.delta": {}, "response.function_call_arguments.delta": {}, "response.function_call_arguments.done": {}, + "response.output_text.delta": {}, "response.output_text.done": {}, + "response.content_part.added": {}, "response.content_part.delta": {}, "response.content_part.done": {}, + "response.function_call_arguments.delta": {}, "response.function_call_arguments.done": {}, "response.custom_tool_call_input.delta": {}, "response.custom_tool_call_input.done": {}, "response.reasoning_summary_text.delta": {}, "response.reasoning_summary_text.done": {}, "response.reasoning_summary_part.added": {}, "response.reasoning_summary_part.done": {}, - "response.output_json.delta": {}, "response.content_part.added": {}, "response.content_part.delta": {}, "response.content_part.done": {}, "response.audio.delta": {}, "response.audio_transcript.delta": {}, + "response.output_json.delta": {}, "response.output_json.done": {}, "response.audio.delta": {}, "response.audio.done": {}, "response.audio_transcript.delta": {}, "response.audio_transcript.done": {}, + "response.refusal.delta": {}, "response.refusal.done": {}, "response.output_item.added": {}, "response.output_item.done": {}, "response.completed": {}, "response.error": {}, "response.failed": {}, "error": {}, "keepalive": {}, } From e49746c4cedeec29624a674df44c6bee633f0a2b Mon Sep 17 00:00:00 2001 From: yukaidi Date: Mon, 24 Aug 2026 16:51:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(responses):=20preflight=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=8E=A5=E5=85=A5reasoning=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E5=85=A8=E8=AF=AD=E4=B9=89=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stream.go:203 preflight循环对OpenRouter原始行调用NormalizeOpenRouterReasoningSSELine - stream_processor.go: HasResponsesSemanticContent增加reasoning_text.delta/.done和reasoning_summary_text.delta - helpers.go: firstUnknownResponsesEventType knownTypes表补全reasoning_text事件 - 测试文件补充新增事件类型的回归用例 根因:preflight循环未归一化导致OpenRouter非标准reasoning事件被误判为空流 --- .../handlers/common/stream_processor.go | 3 ++- .../handler_stream_preflight_test.go | 25 +++++++++++++++++++ .../internal/handlers/responses/helpers.go | 1 + .../internal/handlers/responses/stream.go | 2 +- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend-go/internal/handlers/common/stream_processor.go b/backend-go/internal/handlers/common/stream_processor.go index 4865947e..fd0cfed1 100644 --- a/backend-go/internal/handlers/common/stream_processor.go +++ b/backend-go/internal/handlers/common/stream_processor.go @@ -392,7 +392,8 @@ func HasResponsesSemanticContent(event string) bool { case "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done", "response.reasoning_summary_part.added", "response.reasoning_summary_part.done", - "response.reasoning_summary_text.done": + "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done", + "response.reasoning_text.delta", "response.reasoning_text.done": return true case "response.output_item.added", "response.output_item.done": item, _ := data["item"].(map[string]interface{}) diff --git a/backend-go/internal/handlers/responses/handler_stream_preflight_test.go b/backend-go/internal/handlers/responses/handler_stream_preflight_test.go index 01948f27..e96747de 100644 --- a/backend-go/internal/handlers/responses/handler_stream_preflight_test.go +++ b/backend-go/internal/handlers/responses/handler_stream_preflight_test.go @@ -106,6 +106,27 @@ func TestHasResponsesSemanticContent(t *testing.T) { t.Fatal("expected content_part.added output_text to be treated as semantic content") } }) + + t.Run("reasoning_text delta (OpenRouter non-standard)", func(t *testing.T) { + event := "event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"delta\":\"thinking...\",\"content_index\":0}\n\n" + if !common.HasResponsesSemanticContent(event) { + t.Fatal("expected reasoning_text.delta to be treated as semantic content") + } + }) + + t.Run("reasoning_text done (OpenRouter non-standard)", func(t *testing.T) { + event := "event: response.reasoning_text.done\ndata: {\"type\":\"response.reasoning_text.done\",\"content_index\":0}\n\n" + if !common.HasResponsesSemanticContent(event) { + t.Fatal("expected reasoning_text.done to be treated as semantic content") + } + }) + + t.Run("reasoning_summary_text delta (standard)", func(t *testing.T) { + event := "event: response.reasoning_summary_text.delta\ndata: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"thinking...\",\"summary_index\":0}\n\n" + if !common.HasResponsesSemanticContent(event) { + t.Fatal("expected reasoning_summary_text.delta to be treated as semantic content") + } + }) } func TestExtractResponsesTextFromEventUnknownTypes(t *testing.T) { @@ -246,6 +267,10 @@ func TestFirstUnknownResponsesEventType_AllowsStandardContentPartAndDoneEvents(t "response.audio_transcript.done", "response.function_call_arguments.delta", "response.function_call_arguments.done", + "response.reasoning_text.delta", + "response.reasoning_text.done", + "response.reasoning_summary_text.delta", + "response.reasoning_summary_text.done", } for _, eventType := range eventTypes { t.Run(eventType, func(t *testing.T) { diff --git a/backend-go/internal/handlers/responses/helpers.go b/backend-go/internal/handlers/responses/helpers.go index 6a170266..9e147a6c 100644 --- a/backend-go/internal/handlers/responses/helpers.go +++ b/backend-go/internal/handlers/responses/helpers.go @@ -240,6 +240,7 @@ func firstUnknownResponsesEventType(event string) (string, bool) { "response.function_call_arguments.delta": {}, "response.function_call_arguments.done": {}, "response.custom_tool_call_input.delta": {}, "response.custom_tool_call_input.done": {}, "response.reasoning_summary_text.delta": {}, "response.reasoning_summary_text.done": {}, "response.reasoning_summary_part.added": {}, "response.reasoning_summary_part.done": {}, + "response.reasoning_text.delta": {}, "response.reasoning_text.done": {}, "response.output_json.delta": {}, "response.output_json.done": {}, "response.audio.delta": {}, "response.audio.done": {}, "response.audio_transcript.delta": {}, "response.audio_transcript.done": {}, "response.refusal.delta": {}, "response.refusal.done": {}, "response.output_item.added": {}, "response.output_item.done": {}, "response.completed": {}, diff --git a/backend-go/internal/handlers/responses/stream.go b/backend-go/internal/handlers/responses/stream.go index aa5e168d..18228ef3 100644 --- a/backend-go/internal/handlers/responses/stream.go +++ b/backend-go/internal/handlers/responses/stream.go @@ -200,7 +200,7 @@ func handleStreamSuccess( ) } } else { - eventsToCheck = []string{line + "\n"} + eventsToCheck = []string{converters.NormalizeOpenRouterReasoningSSELine(line) + "\n"} } for _, event := range eventsToCheck { From 7f5bcaf4ba94c1d0d85d8b0f3ec383904eb401a2 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Mon, 24 Aug 2026 20:41:01 +0800 Subject: [PATCH 4/4] fix: all non-blacklisted upstream errors now trigger failover in preflight - Remove isRetryableResponsesError gate: all upstream error events (response.error/response.failed) now wrap with ErrEmptyStreamResponse to trigger key/channel failover during preflight - Blacklisted errors (API key invalid, etc.) still fail immediately - Comment out unused isRetryableResponsesError function --- .../internal/handlers/responses/fold.go | 5 +-- .../internal/handlers/responses/helpers.go | 35 ++++++++++--------- .../internal/handlers/responses/stream.go | 7 ++-- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/backend-go/internal/handlers/responses/fold.go b/backend-go/internal/handlers/responses/fold.go index 9a6a564c..fa51ec5c 100644 --- a/backend-go/internal/handlers/responses/fold.go +++ b/backend-go/internal/handlers/responses/fold.go @@ -355,10 +355,7 @@ func (e *responsesFoldHTTPEmitter) emit(event map[string]interface{}) error { return &common.ErrBlacklistKey{Reason: r, Message: m} } diagnostic := formatResponsesErrorDiagnostic(upstreamErr) - if isRetryableResponsesError(upstreamErr) { - return fmt.Errorf("%w: %s", common.ErrEmptyStreamResponse, diagnostic) - } - return fmt.Errorf("upstream Responses error: %s", diagnostic) + return fmt.Errorf("%w: %s", common.ErrEmptyStreamResponse, diagnostic) } e.preflightEvents = append(e.preflightEvents, eventString) diff --git a/backend-go/internal/handlers/responses/helpers.go b/backend-go/internal/handlers/responses/helpers.go index 9e147a6c..5558870b 100644 --- a/backend-go/internal/handlers/responses/helpers.go +++ b/backend-go/internal/handlers/responses/helpers.go @@ -364,22 +364,25 @@ func detectResponsesErrorBlacklist(info responsesStreamErrorInfo) (reason, messa return common.DetectStreamBlacklistError("event: error\ndata: " + string(payload) + "\n\n") } -func isRetryableResponsesError(info responsesStreamErrorInfo) bool { - code := strings.ToLower(strings.TrimSpace(info.Code)) - errType := strings.ToLower(strings.TrimSpace(info.Type)) - message := strings.ToLower(strings.TrimSpace(info.Message)) - - switch code { - case "server_is_overloaded", "slow_down", "rate_limit_exceeded", "rate_limit", "temporarily_unavailable", - "service_unavailable", "server_error", "internal_error", "timeout": - return true - } - switch errType { - case "service_unavailable_error", "server_error", "rate_limit_error", "timeout_error": - return true - } - return strings.Contains(message, "server") && strings.Contains(message, "overload") -} +// isRetryableResponsesError is no longer used; all non-blacklisted upstream errors +// now trigger failover in the preflight phase. +// +// func isRetryableResponsesError(info responsesStreamErrorInfo) bool { +// code := strings.ToLower(strings.TrimSpace(info.Code)) +// errType := strings.ToLower(strings.TrimSpace(info.Type)) +// message := strings.ToLower(strings.TrimSpace(info.Message)) +// +// switch code { +// case "server_is_overloaded", "slow_down", "rate_limit_exceeded", "rate_limit", "temporarily_unavailable", +// "service_unavailable", "server_error", "internal_error", "timeout": +// return true +// } +// switch errType { +// case "service_unavailable_error", "server_error", "rate_limit_error", "timeout_error": +// return true +// } +// return strings.Contains(message, "server") && strings.Contains(message, "overload") +// } func formatResponsesErrorDiagnostic(info responsesStreamErrorInfo) string { parts := make([]string, 0, 2) diff --git a/backend-go/internal/handlers/responses/stream.go b/backend-go/internal/handlers/responses/stream.go index 18228ef3..bd702708 100644 --- a/backend-go/internal/handlers/responses/stream.go +++ b/backend-go/internal/handlers/responses/stream.go @@ -211,11 +211,8 @@ func handleStreamSuccess( if r, m := detectResponsesErrorBlacklist(upstreamErr); r != "" { return nil, &common.ErrBlacklistKey{Reason: r, Message: m} } - if isRetryableResponsesError(upstreamErr) { - common.RequestLogf(c, "[Responses-UpstreamError] %s,触发重试", preflightDiagnostic) - return nil, fmt.Errorf("%w: %s", common.ErrEmptyStreamResponse, preflightDiagnostic) - } - return nil, fmt.Errorf("upstream Responses error: %s", preflightDiagnostic) + common.RequestLogf(c, "[Responses-UpstreamError] %s,触发重试", preflightDiagnostic) + return nil, fmt.Errorf("%w: %s", common.ErrEmptyStreamResponse, preflightDiagnostic) } hadPendingToolCall := preflightToolTracker.HasPendingToolCall() if malformed, name := preflightToolTracker.ProcessResponsesEvent(event); malformed {