From dde50211470d3fd30cf574b2f6d1dc53fd164795 Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sat, 16 May 2026 15:08:40 +0800 Subject: [PATCH 1/9] fix: preserve reasoning_content for OpenAI-compatible providers DeepSeek, Kimi, and other providers with thinking mode require reasoning_content to be passed back verbatim in multi-turn conversations. The OpenAI compat executor was dropping this field during request translation, causing 400 errors. Add preserveReasoningContent() which restores reasoning_content from the original payload after translation, covering all providers that route through the OpenAI-compatible executor (DeepSeek, OpenRouter, custom models, etc.). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../executor/openai_compat_executor.go | 10 ++ .../runtime/executor/reasoning_preserve.go | 93 ++++++++++ .../executor/reasoning_preserve_test.go | 161 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 internal/runtime/executor/reasoning_preserve.go create mode 100644 internal/runtime/executor/reasoning_preserve_test.go diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 82fc9e97d8d..d45d0e251ad 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -102,6 +102,11 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A return resp, err } + translated, err = preserveReasoningContent(originalPayload, translated) + if err != nil { + return resp, err + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) @@ -206,6 +211,11 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy return nil, err } + translated, err = preserveReasoningContent(originalPayload, translated) + if err != nil { + return nil, err + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go new file mode 100644 index 00000000000..e0f7ad2a3ac --- /dev/null +++ b/internal/runtime/executor/reasoning_preserve.go @@ -0,0 +1,93 @@ +package executor + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// preserveReasoningContent ensures assistant messages in the translated OpenAI-format +// payload retain reasoning_content from the original source payload. +// +// DeepSeek and other providers that support thinking mode require reasoning_content +// to be passed back verbatim in multi-turn conversations. Without this, the API returns +// a 400 error: "The reasoning_content in the thinking mode must be passed back to the API." +func preserveReasoningContent(original, translated []byte) ([]byte, error) { + if len(original) == 0 || len(translated) == 0 { + return translated, nil + } + if !gjson.ValidBytes(original) || !gjson.ValidBytes(translated) { + return translated, nil + } + + origMsgs := gjson.GetBytes(original, "messages") + if !origMsgs.Exists() || !origMsgs.IsArray() { + return translated, nil + } + + // Build a lookup of reasoning_content from original assistant messages. + type reasonEntry struct { + text string + isEmpty bool // true if field existed but was empty + } + origReasoning := make(map[int]reasonEntry) + for i, msg := range origMsgs.Array() { + if strings.TrimSpace(msg.Get("role").String()) != "assistant" { + continue + } + if rc := msg.Get("reasoning_content"); rc.Exists() { + origReasoning[i] = reasonEntry{text: rc.String(), isEmpty: false} + } + } + + if len(origReasoning) == 0 { + return translated, nil + } + + transMsgs := gjson.GetBytes(translated, "messages") + if !transMsgs.Exists() || !transMsgs.IsArray() { + return translated, nil + } + + out := translated + lastReasoning := "" + for i, msg := range transMsgs.Array() { + if strings.TrimSpace(msg.Get("role").String()) != "assistant" { + continue + } + + if entry, ok := origReasoning[i]; ok { + // Original had reasoning_content — preserve it exactly (including empty string). + path := fmt.Sprintf("messages.%d.reasoning_content", i) + next, err := sjson.SetBytes(out, path, entry.text) + if err != nil { + return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) + } + out = next + if strings.TrimSpace(entry.text) != "" { + lastReasoning = entry.text + } + continue + } + + // No reasoning_content in original for this index. + // If the translated payload already has one, keep it. + if msg.Get("reasoning_content").Exists() { + continue + } + + // Inherit from the most recent reasoning if available. + if lastReasoning != "" { + path := fmt.Sprintf("messages.%d.reasoning_content", i) + next, err := sjson.SetBytes(out, path, lastReasoning) + if err != nil { + return translated, fmt.Errorf("preserveReasoningContent: failed to set inherited reasoning_content at index %d: %w", i, err) + } + out = next + } + } + + return out, nil +} diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go new file mode 100644 index 00000000000..8a03f5f443f --- /dev/null +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -0,0 +1,161 @@ +package executor + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestPreserveReasoningContent_PreservesEmptyStringReasoning(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":""}, + {"role":"user","content":"follow up"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"}, + {"role":"user","content":"follow up"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist (even if empty)") + } + if rc.String() != "" { + t.Fatalf("messages.1.reasoning_content = %q, want empty string", rc.String()) + } +} + +func TestPreserveReasoningContent_InheritsReasoningForMissingMessages(t *testing.T) { + // Multi-turn tool call chain: assistant with reasoning → tool → assistant without reasoning + original := []byte(`{ + "messages":[ + {"role":"user","content":"list files"}, + {"role":"assistant","content":"I'll list the files","reasoning_content":"let me check the directory"}, + {"role":"tool","tool_call_id":"call_1","content":"[file1.txt, file2.txt]"}, + {"role":"assistant","content":"Here are the files"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"list files"}, + {"role":"assistant","content":"I'll list the files"}, + {"role":"tool","tool_call_id":"call_1","content":"[file1.txt, file2.txt]"}, + {"role":"assistant","content":"Here are the files"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + // First assistant (index 1) should get the original reasoning + rc1 := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if rc1 != "let me check the directory" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc1, "let me check the directory") + } + + // Second assistant (index 3) should inherit the last reasoning + rc3 := gjson.GetBytes(out, "messages.3.reasoning_content").String() + if rc3 != "let me check the directory" { + t.Fatalf("messages.3.reasoning_content = %q, want %q", rc3, "let me check the directory") + } +} + +func TestPreserveReasoningContent_NoOpWhenNoOriginalReasoning(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { + t.Fatalf("messages.1.reasoning_content should not exist when original has none") + } +} + +func TestPreserveReasoningContent_IgnoresNonAssistantMessages(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"system","content":"you are helpful"}, + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"thinking..."}, + {"role":"tool","tool_call_id":"call_1","content":"data"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"system","content":"you are helpful"}, + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"}, + {"role":"tool","tool_call_id":"call_1","content":"data"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + // Only assistant at index 2 should be affected + if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { + t.Fatalf("system message should not get reasoning_content") + } + if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { + t.Fatalf("user message should not get reasoning_content") + } + if !gjson.GetBytes(out, "messages.2.reasoning_content").Exists() { + t.Fatalf("assistant message should have reasoning_content preserved") + } + if gjson.GetBytes(out, "messages.3.reasoning_content").Exists() { + t.Fatalf("tool message should not get reasoning_content") + } +} + +func TestPreserveReasoningContent_KeepsExistingNonEmptyReasoning(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"let me think..."} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + got := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if got != "let me think..." { + t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "let me think...") + } +} From b19780715138bf6a0217d2bd41d6723f1d78b584 Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sat, 16 May 2026 15:13:23 +0800 Subject: [PATCH 2/9] refactor: simplify reasonEntry to plain string, add message count safety check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused isEmpty field from reasonEntry (now map[int]string) - Add message count mismatch guard to prevent incorrect index-based matching when translation changes message count (e.g. Claude→OpenAI) - Add test for message count mismatch safety Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../runtime/executor/reasoning_preserve.go | 23 +++++++------ .../executor/reasoning_preserve_test.go | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go index e0f7ad2a3ac..b7d01bf0916 100644 --- a/internal/runtime/executor/reasoning_preserve.go +++ b/internal/runtime/executor/reasoning_preserve.go @@ -28,17 +28,13 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { } // Build a lookup of reasoning_content from original assistant messages. - type reasonEntry struct { - text string - isEmpty bool // true if field existed but was empty - } - origReasoning := make(map[int]reasonEntry) + origReasoning := make(map[int]string) for i, msg := range origMsgs.Array() { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { continue } if rc := msg.Get("reasoning_content"); rc.Exists() { - origReasoning[i] = reasonEntry{text: rc.String(), isEmpty: false} + origReasoning[i] = rc.String() } } @@ -51,6 +47,13 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { return translated, nil } + // Index-based matching is only safe when message counts align. + // When translation changes message count (e.g. Claude→OpenAI merges blocks), + // skip preservation — those formats don't use reasoning_content anyway. + if len(origMsgs.Array()) != len(transMsgs.Array()) { + return translated, nil + } + out := translated lastReasoning := "" for i, msg := range transMsgs.Array() { @@ -58,16 +61,16 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { continue } - if entry, ok := origReasoning[i]; ok { + if text, ok := origReasoning[i]; ok { // Original had reasoning_content — preserve it exactly (including empty string). path := fmt.Sprintf("messages.%d.reasoning_content", i) - next, err := sjson.SetBytes(out, path, entry.text) + next, err := sjson.SetBytes(out, path, text) if err != nil { return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) } out = next - if strings.TrimSpace(entry.text) != "" { - lastReasoning = entry.text + if strings.TrimSpace(text) != "" { + lastReasoning = text } continue } diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go index 8a03f5f443f..ef9b288a001 100644 --- a/internal/runtime/executor/reasoning_preserve_test.go +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -159,3 +159,35 @@ func TestPreserveReasoningContent_KeepsExistingNonEmptyReasoning(t *testing.T) { t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "let me think...") } } + +func TestPreserveReasoningContent_SkipsWhenMessageCountMismatch(t *testing.T) { + // Claude→OpenAI translation can merge content blocks, changing message count. + // In this case the function should skip to avoid incorrect index-based matching. + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"thinking..."} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"system","content":"you are helpful"}, + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + // Should not inject reasoning into a wrong index + if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { + t.Fatalf("user message at index 1 should not get reasoning_content from mismatched index") + } + // Translated assistant (index 2) should not get original's reasoning (index 1) + if gjson.GetBytes(out, "messages.2.reasoning_content").Exists() { + t.Fatalf("assistant should not get reasoning from mismatched index") + } +} From e5dd3cdc010e24a2dada311760b9dcb1702b4aac Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sat, 16 May 2026 15:20:53 +0800 Subject: [PATCH 3/9] test: cover translated reasoning_content preservation when original lacks it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test for the code path where original has no reasoning_content but translated already has one — verify the translated value is kept unchanged. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../executor/reasoning_preserve_test.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go index ef9b288a001..fc7a9a5863a 100644 --- a/internal/runtime/executor/reasoning_preserve_test.go +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -160,6 +160,32 @@ func TestPreserveReasoningContent_KeepsExistingNonEmptyReasoning(t *testing.T) { } } +func TestPreserveReasoningContent_KeepsTranslatedReasoningWhenOriginalLacksIt(t *testing.T) { + // Original has no reasoning_content, but translated already has one — keep it. + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"from upstream"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + got := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if got != "from upstream" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "from upstream") + } +} + func TestPreserveReasoningContent_SkipsWhenMessageCountMismatch(t *testing.T) { // Claude→OpenAI translation can merge content blocks, changing message count. // In this case the function should skip to avoid incorrect index-based matching. From 1e83302793ab1ace0db40125751ae0bc5ca3308c Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sat, 16 May 2026 15:22:37 +0800 Subject: [PATCH 4/9] fix: remove reasoning_content inheritance to avoid fabricating stale reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve reasoning_content only where the original message had it (including empty string ""). Do not inherit from prior assistant messages — providers expect per-message values, not stale reasoning attached to a different response. Also adds missing test coverage for the "keep translated reasoning when original lacks it" code path. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../runtime/executor/reasoning_preserve.go | 35 +++++-------------- .../executor/reasoning_preserve_test.go | 12 +++---- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go index b7d01bf0916..526a494cecb 100644 --- a/internal/runtime/executor/reasoning_preserve.go +++ b/internal/runtime/executor/reasoning_preserve.go @@ -55,41 +55,24 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { } out := translated - lastReasoning := "" for i, msg := range transMsgs.Array() { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { continue } - if text, ok := origReasoning[i]; ok { - // Original had reasoning_content — preserve it exactly (including empty string). - path := fmt.Sprintf("messages.%d.reasoning_content", i) - next, err := sjson.SetBytes(out, path, text) - if err != nil { - return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) - } - out = next - if strings.TrimSpace(text) != "" { - lastReasoning = text - } + text, ok := origReasoning[i] + if !ok { + // No reasoning_content in original — leave translated as-is. continue } - // No reasoning_content in original for this index. - // If the translated payload already has one, keep it. - if msg.Get("reasoning_content").Exists() { - continue - } - - // Inherit from the most recent reasoning if available. - if lastReasoning != "" { - path := fmt.Sprintf("messages.%d.reasoning_content", i) - next, err := sjson.SetBytes(out, path, lastReasoning) - if err != nil { - return translated, fmt.Errorf("preserveReasoningContent: failed to set inherited reasoning_content at index %d: %w", i, err) - } - out = next + // Original had reasoning_content — preserve it exactly (including empty string). + path := fmt.Sprintf("messages.%d.reasoning_content", i) + next, err := sjson.SetBytes(out, path, text) + if err != nil { + return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) } + out = next } return out, nil diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go index fc7a9a5863a..a48e327291b 100644 --- a/internal/runtime/executor/reasoning_preserve_test.go +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -36,8 +36,9 @@ func TestPreserveReasoningContent_PreservesEmptyStringReasoning(t *testing.T) { } } -func TestPreserveReasoningContent_InheritsReasoningForMissingMessages(t *testing.T) { - // Multi-turn tool call chain: assistant with reasoning → tool → assistant without reasoning +func TestPreserveReasoningContent_DoesNotInheritReasoningForMissingMessages(t *testing.T) { + // Multi-turn tool call chain: assistant with reasoning → tool → assistant without reasoning. + // The second assistant originally had no reasoning_content, so it must not get one fabricated. original := []byte(`{ "messages":[ {"role":"user","content":"list files"}, @@ -66,10 +67,9 @@ func TestPreserveReasoningContent_InheritsReasoningForMissingMessages(t *testing t.Fatalf("messages.1.reasoning_content = %q, want %q", rc1, "let me check the directory") } - // Second assistant (index 3) should inherit the last reasoning - rc3 := gjson.GetBytes(out, "messages.3.reasoning_content").String() - if rc3 != "let me check the directory" { - t.Fatalf("messages.3.reasoning_content = %q, want %q", rc3, "let me check the directory") + // Second assistant (index 3) originally had no reasoning — must remain absent + if gjson.GetBytes(out, "messages.3.reasoning_content").Exists() { + t.Fatalf("messages.3.reasoning_content should not exist when original had none") } } From 6297acea650f58d1b561fa3688e3d23644cfef40 Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sat, 16 May 2026 15:24:02 +0800 Subject: [PATCH 5/9] perf: cache array results and pre-allocate map per Gemini review feedback - Cache origMsgs.Array() and transMsgs.Array() to avoid redundant JSON parsing - Pre-allocate origReasoning map with len(origMsgArr) capacity - Move map building after message count check to skip unnecessary work Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../runtime/executor/reasoning_preserve.go | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go index 526a494cecb..e11fcac6c87 100644 --- a/internal/runtime/executor/reasoning_preserve.go +++ b/internal/runtime/executor/reasoning_preserve.go @@ -26,10 +26,24 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { if !origMsgs.Exists() || !origMsgs.IsArray() { return translated, nil } + origMsgArr := origMsgs.Array() + + transMsgs := gjson.GetBytes(translated, "messages") + if !transMsgs.Exists() || !transMsgs.IsArray() { + return translated, nil + } + transMsgArr := transMsgs.Array() + + // Index-based matching is only safe when message counts align. + // When translation changes message count (e.g. Claude→OpenAI merges blocks), + // skip preservation — those formats don't use reasoning_content anyway. + if len(origMsgArr) != len(transMsgArr) { + return translated, nil + } // Build a lookup of reasoning_content from original assistant messages. - origReasoning := make(map[int]string) - for i, msg := range origMsgs.Array() { + origReasoning := make(map[int]string, len(origMsgArr)) + for i, msg := range origMsgArr { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { continue } @@ -42,20 +56,8 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { return translated, nil } - transMsgs := gjson.GetBytes(translated, "messages") - if !transMsgs.Exists() || !transMsgs.IsArray() { - return translated, nil - } - - // Index-based matching is only safe when message counts align. - // When translation changes message count (e.g. Claude→OpenAI merges blocks), - // skip preservation — those formats don't use reasoning_content anyway. - if len(origMsgs.Array()) != len(transMsgs.Array()) { - return translated, nil - } - out := translated - for i, msg := range transMsgs.Array() { + for i, msg := range transMsgArr { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { continue } From 845721e5c955f081d22eb9ba247e08b6a620729a Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sun, 17 May 2026 00:47:48 +0800 Subject: [PATCH 6/9] fix: preserve reasoning_content across message count mismatches and Responses API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous preserveReasoningContent fix had three fundamental flaws: 1. It skipped ALL reasoning_content recovery when original and translated message counts differed (e.g. Claude→OpenAI tool_result splitting). Now uses assistant-ordinal matching instead of index matching. 2. ApplyPayloadConfigWithRoot could override/delete reasoning_content after preserveReasoningContent ran. Now preserveReasoningContent is called again after ApplyPayloadConfigWithRoot to re-assert the values. 3. ConvertOpenAIResponsesRequestToOpenAIChatCompletions did not convert reasoning_content from Responses format input items. Now assistant messages carry over reasoning_content during format conversion. 🤖 Generated with CodeMate --- .../executor/openai_compat_executor.go | 22 +++- .../runtime/executor/reasoning_preserve.go | 63 ++++----- .../executor/reasoning_preserve_test.go | 123 +++++++++++++++--- .../openai_openai-responses_request.go | 6 + .../openai_openai-responses_request_test.go | 42 ++++++ 5 files changed, 209 insertions(+), 47 deletions(-) diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index d45d0e251ad..a8e49927791 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -102,7 +102,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A return resp, err } - translated, err = preserveReasoningContent(originalPayload, translated) + translated, err = preserveReasoningContent(originalTranslated, translated) if err != nil { return resp, err } @@ -110,6 +110,12 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) + + translated, err = preserveReasoningContent(originalTranslated, translated) + if err != nil { + return resp, err + } + if opts.Alt == "responses/compact" { if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil { translated = updated @@ -198,6 +204,11 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy from := opts.SourceFormat to := sdktranslator.FromString("openai") + endpoint := "/chat/completions" + if opts.Alt == "responses/compact" { + to = sdktranslator.FromString("openai-response") + endpoint = "/responses/compact" + } originalPayloadSource := req.Payload if len(opts.OriginalRequest) > 0 { originalPayloadSource = opts.OriginalRequest @@ -211,7 +222,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy return nil, err } - translated, err = preserveReasoningContent(originalPayload, translated) + translated, err = preserveReasoningContent(originalTranslated, translated) if err != nil { return nil, err } @@ -220,11 +231,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) + translated, err = preserveReasoningContent(originalTranslated, translated) + if err != nil { + return nil, err + } + // Request usage data in the final streaming chunk so that token statistics // are captured even when the upstream is an OpenAI-compatible provider. translated, _ = sjson.SetBytes(translated, "stream_options.include_usage", true) - url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" + url := strings.TrimSuffix(baseURL, "/") + endpoint httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) if err != nil { return nil, err diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go index e11fcac6c87..c3bdb2f4c76 100644 --- a/internal/runtime/executor/reasoning_preserve.go +++ b/internal/runtime/executor/reasoning_preserve.go @@ -14,6 +14,12 @@ import ( // DeepSeek and other providers that support thinking mode require reasoning_content // to be passed back verbatim in multi-turn conversations. Without this, the API returns // a 400 error: "The reasoning_content in the thinking mode must be passed back to the API." +// +// Matching strategy: instead of requiring identical message counts (which breaks when +// translation inserts/splits messages like Claude tool_result → tool role), we match +// assistant messages by their ordinal position within the assistant-only sequence. +// This is robust because translation never reorders or drops assistant messages — +// it only inserts non-assistant messages (tool, system) around them. func preserveReasoningContent(original, translated []byte) ([]byte, error) { if len(original) == 0 || len(translated) == 0 { return translated, nil @@ -34,48 +40,47 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { } transMsgArr := transMsgs.Array() - // Index-based matching is only safe when message counts align. - // When translation changes message count (e.g. Claude→OpenAI merges blocks), - // skip preservation — those formats don't use reasoning_content anyway. - if len(origMsgArr) != len(transMsgArr) { + origReasoning := collectAssistantReasoning(origMsgArr) + if len(origReasoning) == 0 { return translated, nil } - // Build a lookup of reasoning_content from original assistant messages. - origReasoning := make(map[int]string, len(origMsgArr)) - for i, msg := range origMsgArr { + out := translated + assistantOrdinal := 0 + for i, msg := range transMsgArr { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { continue } - if rc := msg.Get("reasoning_content"); rc.Exists() { - origReasoning[i] = rc.String() + + text, ok := origReasoning[assistantOrdinal] + if ok { + path := fmt.Sprintf("messages.%d.reasoning_content", i) + next, err := sjson.SetBytes(out, path, text) + if err != nil { + return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) + } + out = next } + assistantOrdinal++ } - if len(origReasoning) == 0 { - return translated, nil - } + return out, nil +} - out := translated - for i, msg := range transMsgArr { +// collectAssistantReasoning extracts reasoning_content from assistant messages, +// keyed by their ordinal position in the assistant-only sequence (0, 1, 2, ...). +// Empty-string reasoning_content is preserved because DeepSeek requires it. +func collectAssistantReasoning(messages []gjson.Result) map[int]string { + reasoning := make(map[int]string, len(messages)) + ordinal := 0 + for _, msg := range messages { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { continue } - - text, ok := origReasoning[i] - if !ok { - // No reasoning_content in original — leave translated as-is. - continue - } - - // Original had reasoning_content — preserve it exactly (including empty string). - path := fmt.Sprintf("messages.%d.reasoning_content", i) - next, err := sjson.SetBytes(out, path, text) - if err != nil { - return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) + if rc := msg.Get("reasoning_content"); rc.Exists() { + reasoning[ordinal] = rc.String() } - out = next + ordinal++ } - - return out, nil + return reasoning } diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go index a48e327291b..09b2d95d14e 100644 --- a/internal/runtime/executor/reasoning_preserve_test.go +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -37,8 +37,6 @@ func TestPreserveReasoningContent_PreservesEmptyStringReasoning(t *testing.T) { } func TestPreserveReasoningContent_DoesNotInheritReasoningForMissingMessages(t *testing.T) { - // Multi-turn tool call chain: assistant with reasoning → tool → assistant without reasoning. - // The second assistant originally had no reasoning_content, so it must not get one fabricated. original := []byte(`{ "messages":[ {"role":"user","content":"list files"}, @@ -61,13 +59,11 @@ func TestPreserveReasoningContent_DoesNotInheritReasoningForMissingMessages(t *t t.Fatalf("preserveReasoningContent() error = %v", err) } - // First assistant (index 1) should get the original reasoning rc1 := gjson.GetBytes(out, "messages.1.reasoning_content").String() if rc1 != "let me check the directory" { t.Fatalf("messages.1.reasoning_content = %q, want %q", rc1, "let me check the directory") } - // Second assistant (index 3) originally had no reasoning — must remain absent if gjson.GetBytes(out, "messages.3.reasoning_content").Exists() { t.Fatalf("messages.3.reasoning_content should not exist when original had none") } @@ -120,7 +116,6 @@ func TestPreserveReasoningContent_IgnoresNonAssistantMessages(t *testing.T) { t.Fatalf("preserveReasoningContent() error = %v", err) } - // Only assistant at index 2 should be affected if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { t.Fatalf("system message should not get reasoning_content") } @@ -161,7 +156,6 @@ func TestPreserveReasoningContent_KeepsExistingNonEmptyReasoning(t *testing.T) { } func TestPreserveReasoningContent_KeepsTranslatedReasoningWhenOriginalLacksIt(t *testing.T) { - // Original has no reasoning_content, but translated already has one — keep it. original := []byte(`{ "messages":[ {"role":"user","content":"hello"}, @@ -186,9 +180,7 @@ func TestPreserveReasoningContent_KeepsTranslatedReasoningWhenOriginalLacksIt(t } } -func TestPreserveReasoningContent_SkipsWhenMessageCountMismatch(t *testing.T) { - // Claude→OpenAI translation can merge content blocks, changing message count. - // In this case the function should skip to avoid incorrect index-based matching. +func TestPreserveReasoningContent_OrdinalMatchingAcrossMessageCountMismatch(t *testing.T) { original := []byte(`{ "messages":[ {"role":"user","content":"hello"}, @@ -208,12 +200,113 @@ func TestPreserveReasoningContent_SkipsWhenMessageCountMismatch(t *testing.T) { t.Fatalf("preserveReasoningContent() error = %v", err) } - // Should not inject reasoning into a wrong index - if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { - t.Fatalf("user message at index 1 should not get reasoning_content from mismatched index") + rc := gjson.GetBytes(out, "messages.2.reasoning_content") + if !rc.Exists() { + t.Fatalf("assistant message (ordinal 0) should have reasoning_content preserved despite message count mismatch") + } + if rc.String() != "thinking..." { + t.Fatalf("messages.2.reasoning_content = %q, want %q", rc.String(), "thinking...") } - // Translated assistant (index 2) should not get original's reasoning (index 1) - if gjson.GetBytes(out, "messages.2.reasoning_content").Exists() { - t.Fatalf("assistant should not get reasoning from mismatched index") +} + +func TestPreserveReasoningContent_OrdinalMatchingMultipleAssistants(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer1","reasoning_content":"think1"}, + {"role":"user","content":"more"}, + {"role":"assistant","content":"answer2","reasoning_content":"think2"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"system","content":"system"}, + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer1"}, + {"role":"user","content":"more"}, + {"role":"assistant","content":"answer2"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + rc1 := gjson.GetBytes(out, "messages.2.reasoning_content") + if !rc1.Exists() || rc1.String() != "think1" { + t.Fatalf("first assistant (ordinal 0): got %q, want %q", rc1.String(), "think1") + } + + rc2 := gjson.GetBytes(out, "messages.4.reasoning_content") + if !rc2.Exists() || rc2.String() != "think2" { + t.Fatalf("second assistant (ordinal 1): got %q, want %q", rc2.String(), "think2") + } +} + +func TestPreserveReasoningContent_OrdinalMatchingWithToolCalls(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"list files"}, + {"role":"assistant","content":"I'll check","reasoning_content":"need to ls","tool_calls":[{"id":"c1","type":"function","function":{"name":"ls","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"file1.txt"}, + {"role":"assistant","content":"Here are the files","reasoning_content":"got the list"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"list files"}, + {"role":"assistant","content":"I'll check","tool_calls":[{"id":"c1","type":"function","function":{"name":"ls","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"file1.txt"}, + {"role":"assistant","content":"Here are the files"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + rc1 := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if rc1 != "need to ls" { + t.Fatalf("first assistant reasoning = %q, want %q", rc1, "need to ls") + } + + rc2 := gjson.GetBytes(out, "messages.3.reasoning_content").String() + if rc2 != "got the list" { + t.Fatalf("second assistant reasoning = %q, want %q", rc2, "got the list") + } +} + +func TestPreserveReasoningContent_PartialAssistantReasoning(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer1","reasoning_content":"thinking..."}, + {"role":"user","content":"more"}, + {"role":"assistant","content":"answer2"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer1"}, + {"role":"user","content":"more"}, + {"role":"assistant","content":"answer2"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + rc1 := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if rc1 != "thinking..." { + t.Fatalf("first assistant reasoning = %q, want %q", rc1, "thinking...") + } + + if gjson.GetBytes(out, "messages.3.reasoning_content").Exists() { + t.Fatalf("second assistant should not have reasoning_content when original had none") } } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 15acf7cdb4f..696b98205d1 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -170,6 +170,12 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu message, _ = sjson.SetBytes(message, "content", content.String()) } + if role == "assistant" { + if rc := item.Get("reasoning_content"); rc.Exists() { + message, _ = sjson.SetBytes(message, "reasoning_content", rc.String()) + } + } + appendRegularMessage(message) case "function_call": diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index 9dd0e288b2c..58aa85efc8f 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -122,3 +122,45 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntil t.Fatalf("messages.3.content = %q, want %q", got, "next") } } + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningContentPreserved(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"reasoning_content":"thinking step by step"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"follow up"}]} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, true) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist") + } + if rc.String() != "thinking step by step" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "thinking step by step") + } + + if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { + t.Fatalf("user message should not have reasoning_content") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningContentOnlyOnAssistant(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}],"reasoning_content":"should not transfer"}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, false) + + if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { + t.Fatalf("user message should not have reasoning_content even if original had it") + } + if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { + t.Fatalf("assistant message should not have reasoning_content when original had none") + } +} From 5ca1322a3b0c1a79f05d38dcf4526404acfc1c17 Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sun, 17 May 2026 01:08:33 +0800 Subject: [PATCH 7/9] refactor: address review feedback on reasoning_content preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant first preserveReasoningContent call; only call after ApplyPayloadConfigWithRoot to avoid doubling JSON parse cost. - Simplify originalPayload alias variable (eliminate originalPayloadSource). - Document error contract: on sjson failure, unmodified translated is returned so caller never receives a partially-patched payload. - Document original-wins-over-translated semantics explicitly. - Add test for original vs translated reasoning_content conflict. - Remove over-allocation hint in collectAssistantReasoning map. 🤖 Generated with CodeMate --- .../executor/openai_compat_executor.go | 20 +++------------ .../runtime/executor/reasoning_preserve.go | 10 +++++++- .../executor/reasoning_preserve_test.go | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index a8e49927791..25162febfab 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -89,11 +89,10 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A to = sdktranslator.FromString("openai-response") endpoint = "/responses/compact" } - originalPayloadSource := req.Payload + originalPayload := req.Payload if len(opts.OriginalRequest) > 0 { - originalPayloadSource = opts.OriginalRequest + originalPayload = opts.OriginalRequest } - originalPayload := originalPayloadSource originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream) @@ -102,11 +101,6 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A return resp, err } - translated, err = preserveReasoningContent(originalTranslated, translated) - if err != nil { - return resp, err - } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) @@ -209,11 +203,10 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy to = sdktranslator.FromString("openai-response") endpoint = "/responses/compact" } - originalPayloadSource := req.Payload + originalPayload := req.Payload if len(opts.OriginalRequest) > 0 { - originalPayloadSource = opts.OriginalRequest + originalPayload = opts.OriginalRequest } - originalPayload := originalPayloadSource originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true) translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) @@ -222,11 +215,6 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy return nil, err } - translated, err = preserveReasoningContent(originalTranslated, translated) - if err != nil { - return nil, err - } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath) diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go index c3bdb2f4c76..a66af2e1a1b 100644 --- a/internal/runtime/executor/reasoning_preserve.go +++ b/internal/runtime/executor/reasoning_preserve.go @@ -20,6 +20,14 @@ import ( // assistant messages by their ordinal position within the assistant-only sequence. // This is robust because translation never reorders or drops assistant messages — // it only inserts non-assistant messages (tool, system) around them. +// +// When both original and translated carry reasoning_content at the same assistant ordinal, +// the original value always wins — it is the authoritative source the provider expects +// to receive back verbatim. +// +// Error contract: on sjson.SetBytes failure, the function discards any partial writes +// and returns the unmodified translated input along with the error, so the caller never +// receives a partially-patched payload. func preserveReasoningContent(original, translated []byte) ([]byte, error) { if len(original) == 0 || len(translated) == 0 { return translated, nil @@ -71,7 +79,7 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { // keyed by their ordinal position in the assistant-only sequence (0, 1, 2, ...). // Empty-string reasoning_content is preserved because DeepSeek requires it. func collectAssistantReasoning(messages []gjson.Result) map[int]string { - reasoning := make(map[int]string, len(messages)) + reasoning := make(map[int]string) ordinal := 0 for _, msg := range messages { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go index 09b2d95d14e..9745a581d67 100644 --- a/internal/runtime/executor/reasoning_preserve_test.go +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -310,3 +310,28 @@ func TestPreserveReasoningContent_PartialAssistantReasoning(t *testing.T) { t.Fatalf("second assistant should not have reasoning_content when original had none") } } + +func TestPreserveReasoningContent_OriginalWinsOverTranslated(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"original reasoning"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"stale translated reasoning"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + got := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if got != "original reasoning" { + t.Fatalf("original must win over translated: got %q, want %q", got, "original reasoning") + } +} From 14c560d1f9a7b89eb52fe593d0fed206c2164016 Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sun, 17 May 2026 01:36:24 +0800 Subject: [PATCH 8/9] fix: preserve reasoning items in follow-up requests and respect payload filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Responses reasoning items (type:"reasoning") were silently dropped when converting follow-up requests to Chat Completions format because the switch lacked a "reasoning" case. Now summary/encrypted_content from reasoning items are carried into the next assistant message's reasoning_content field. 2. preserveReasoningContent unconditionally overwrote reasoning_content after ApplyPayloadConfigWithRoot, making payload filter/override configs ineffective. Now it only fills in reasoning_content when the translated payload lacks it, respecting explicit user modifications. 🤖 Generated with CodeMate --- .../runtime/executor/reasoning_preserve.go | 14 ++-- .../executor/reasoning_preserve_test.go | 63 +++++++++++++- .../openai_openai-responses_request.go | 20 +++++ .../openai_openai-responses_request_test.go | 84 +++++++++++++++++++ 4 files changed, 173 insertions(+), 8 deletions(-) diff --git a/internal/runtime/executor/reasoning_preserve.go b/internal/runtime/executor/reasoning_preserve.go index a66af2e1a1b..16f39bc32a8 100644 --- a/internal/runtime/executor/reasoning_preserve.go +++ b/internal/runtime/executor/reasoning_preserve.go @@ -21,9 +21,10 @@ import ( // This is robust because translation never reorders or drops assistant messages — // it only inserts non-assistant messages (tool, system) around them. // -// When both original and translated carry reasoning_content at the same assistant ordinal, -// the original value always wins — it is the authoritative source the provider expects -// to receive back verbatim. +// When the translated payload already carries reasoning_content at a given assistant +// ordinal (e.g. from a payload override or from translation), that value is preserved — +// the user or translator has explicitly set it and their intent takes precedence. +// Only when reasoning_content is missing do we fall back to the original value. // // Error contract: on sjson.SetBytes failure, the function discards any partial writes // and returns the unmodified translated input along with the error, so the caller never @@ -60,10 +61,11 @@ func preserveReasoningContent(original, translated []byte) ([]byte, error) { continue } - text, ok := origReasoning[assistantOrdinal] - if ok { + origText, origOK := origReasoning[assistantOrdinal] + transRC := msg.Get("reasoning_content") + if origOK && !transRC.Exists() { path := fmt.Sprintf("messages.%d.reasoning_content", i) - next, err := sjson.SetBytes(out, path, text) + next, err := sjson.SetBytes(out, path, origText) if err != nil { return translated, fmt.Errorf("preserveReasoningContent: failed to set reasoning_content at index %d: %w", i, err) } diff --git a/internal/runtime/executor/reasoning_preserve_test.go b/internal/runtime/executor/reasoning_preserve_test.go index 9745a581d67..4a021b27db4 100644 --- a/internal/runtime/executor/reasoning_preserve_test.go +++ b/internal/runtime/executor/reasoning_preserve_test.go @@ -321,7 +321,7 @@ func TestPreserveReasoningContent_OriginalWinsOverTranslated(t *testing.T) { translated := []byte(`{ "messages":[ {"role":"user","content":"hello"}, - {"role":"assistant","content":"answer","reasoning_content":"stale translated reasoning"} + {"role":"assistant","content":"answer"} ] }`) @@ -332,6 +332,65 @@ func TestPreserveReasoningContent_OriginalWinsOverTranslated(t *testing.T) { got := gjson.GetBytes(out, "messages.1.reasoning_content").String() if got != "original reasoning" { - t.Fatalf("original must win over translated: got %q, want %q", got, "original reasoning") + t.Fatalf("original must fill in when translated lacks reasoning_content: got %q, want %q", got, "original reasoning") + } +} + +func TestPreserveReasoningContent_TranslatedOverrideTakesPrecedence(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"original reasoning"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"override reasoning"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + got := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if got != "override reasoning" { + t.Fatalf("translated override must take precedence over original: got %q, want %q", got, "override reasoning") + } +} + +func TestPreserveReasoningContent_FilterRemovalIsRespected(t *testing.T) { + original := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer","reasoning_content":"original reasoning"}, + {"role":"user","content":"follow up"}, + {"role":"assistant","content":"answer2","reasoning_content":"original reasoning2"} + ] + }`) + translated := []byte(`{ + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":"answer"}, + {"role":"user","content":"follow up"}, + {"role":"assistant","content":"answer2","reasoning_content":"filtered replacement"} + ] + }`) + + out, err := preserveReasoningContent(original, translated) + if err != nil { + t.Fatalf("preserveReasoningContent() error = %v", err) + } + + rc1 := gjson.GetBytes(out, "messages.1.reasoning_content").String() + if rc1 != "original reasoning" { + t.Fatalf("first assistant: original fills in when translated lacks reasoning_content: got %q, want %q", rc1, "original reasoning") + } + + rc2 := gjson.GetBytes(out, "messages.3.reasoning_content").String() + if rc2 != "filtered replacement" { + t.Fatalf("second assistant: translated override takes precedence: got %q, want %q", rc2, "filtered replacement") } } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 696b98205d1..812dc723f24 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -74,6 +74,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu pendingToolCallIDs := make([]string, 0) awaitingToolOutputs := make(map[string]struct{}) deferredMessages := make([][]byte, 0) + pendingReasoningContent := "" flushPendingToolCalls := func() { if len(pendingToolCalls) == 0 { @@ -173,6 +174,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if role == "assistant" { if rc := item.Get("reasoning_content"); rc.Exists() { message, _ = sjson.SetBytes(message, "reasoning_content", rc.String()) + } else if pendingReasoningContent != "" { + message, _ = sjson.SetBytes(message, "reasoning_content", pendingReasoningContent) + pendingReasoningContent = "" } } @@ -219,6 +223,22 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 { flushDeferredMessages() } + + case "reasoning": + if summary := item.Get("summary"); summary.Exists() && summary.IsArray() { + var textParts []string + summary.ForEach(func(_, s gjson.Result) bool { + if t := s.Get("text"); t.Exists() { + textParts = append(textParts, t.String()) + } + return true + }) + if len(textParts) > 0 { + pendingReasoningContent = strings.Join(textParts, "") + } + } else if ec := item.Get("encrypted_content"); ec.Exists() && ec.String() != "" { + pendingReasoningContent = ec.String() + } } } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index 58aa85efc8f..774ba063ef1 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -164,3 +164,87 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningContentOn t.Fatalf("assistant message should not have reasoning_content when original had none") } } + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemSummary(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_abc","summary":[{"type":"summary_text","text":"thinking step by step"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"follow up"}]} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, true) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist from reasoning item summary") + } + if rc.String() != "thinking step by step" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "thinking step by step") + } + + if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { + t.Fatalf("user message should not have reasoning_content") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemEncryptedContent(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_abc","encrypted_content":"encrypted_reasoning_data"}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, false) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist from reasoning item encrypted_content") + } + if rc.String() != "encrypted_reasoning_data" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "encrypted_reasoning_data") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemSummaryMultipleParts(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_abc","summary":[{"type":"summary_text","text":"part1"},{"type":"summary_text","text":"part2"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, true) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist from reasoning item summary") + } + if rc.String() != "part1part2" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "part1part2") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemFallsBackToMessageRC(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"reasoning_content":"from message rc"} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, true) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist from message reasoning_content") + } + if rc.String() != "from message rc" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "from message rc") + } +} From d2ddc4cd2dbedc6b9c36f05f426f1e1d66d97525 Mon Sep 17 00:00:00 2001 From: chujian <765781379@qq.com> Date: Sun, 17 May 2026 10:15:21 +0800 Subject: [PATCH 9/9] fix(responses): preserve reasoning across tool follow-ups --- AGENTS.md | 28 ++++++++ .../openai_openai-responses_request.go | 17 ++++- .../openai_openai-responses_request_test.go | 71 +++++++++++++++++++ .../openai_openai-responses_response.go | 9 +-- .../openai_openai-responses_response_test.go | 52 ++++++++++++++ 5 files changed, 170 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 57027473d7b..79631a6fe78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,3 +56,31 @@ go build -o test-output ./cmd/server && rm test-output # Verify compile (REQUIRE - Use logrus structured logging; avoid leaking secrets/tokens in logs - Avoid panics in HTTP handlers; prefer logged errors and meaningful HTTP status codes - Timeouts are allowed only during credential acquisition; after an upstream connection is established, do not set timeouts for any subsequent network behavior. Intentional exceptions that must remain allowed are the Codex websocket liveness deadlines in `internal/runtime/executor/codex_websockets_executor.go`, the wsrelay session deadlines in `internal/wsrelay/session.go`, the management APICall timeout in `internal/api/handlers/management/api_tools.go`, and the `cmd/fetch_antigravity_models` utility timeouts + +### [Testing] Baseline full-suite failures can be unrelated to the current patch +Detailed description: +Running `go test ./...` on `fix/preserve-reasoning-content` surfaced existing failures outside the Responses reasoning follow-up work: +- `internal/registry`: `TestCodexFreeModelsExcludeGPT55` +- `internal/runtime/executor`: `TestEnsureAccessToken_WarmTokenLoadsCreditsHint` +- `internal/runtime/executor`: `TestUpdateAntigravityCreditsBalance_LoadCodeAssistUserAgent` +These failures can block "green full suite" expectations even when the modified package under review is passing. + +Impact scope: +AI agents reviewing or preparing commits for narrowly scoped translator/request fixes may incorrectly assume their patch caused unrelated red tests, delaying or broadening the change unnecessarily. + +Suggested solutions: +- Record both the full-suite result and the package-scoped result when reporting verification. +- For Responses reasoning fixes, verify at minimum `go test ./internal/translator/openai/openai/responses` and `go build -o test-output ./cmd/server`. +- Treat unrelated full-suite failures as baseline noise unless the diff touches the failing package. + +### [Change Scope] Do not mix unverified local executor refactors into Responses-only fixes +Detailed description: +The working tree may contain extra local edits under `internal/runtime/executor/` that are not required for a Responses translator issue. In this session, `internal/runtime/executor/reasoning_preserve.go` included a separate strategy change that rebuilds the entire `messages` array after patching reasoning fields. That implementation detail is broader than the Responses follow-up fix and needs its own dedicated validation before inclusion. + +Impact scope: +If an agent stages all modified files blindly, a small Responses bugfix commit can accidentally absorb executor behavior changes that were not part of the same root cause or acceptance scope. + +Suggested solutions: +- Stage only files directly tied to the issue being fixed. +- When executor-side reasoning preservation logic changes independently, add focused tests for the specific reconstruction strategy before committing it. +- Call out excluded local files explicitly in the handoff or commit summary. diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 812dc723f24..7cd6264ddba 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -75,6 +75,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu awaitingToolOutputs := make(map[string]struct{}) deferredMessages := make([][]byte, 0) pendingReasoningContent := "" + pendingReasoningContentSet := false flushPendingToolCalls := func() { if len(pendingToolCalls) == 0 { @@ -82,6 +83,11 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`) assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls) + if pendingReasoningContentSet { + assistantMessage, _ = sjson.SetBytes(assistantMessage, "reasoning_content", pendingReasoningContent) + pendingReasoningContent = "" + pendingReasoningContentSet = false + } out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage) for _, id := range pendingToolCallIDs { if strings.TrimSpace(id) == "" { @@ -174,9 +180,10 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if role == "assistant" { if rc := item.Get("reasoning_content"); rc.Exists() { message, _ = sjson.SetBytes(message, "reasoning_content", rc.String()) - } else if pendingReasoningContent != "" { + } else if pendingReasoningContentSet { message, _ = sjson.SetBytes(message, "reasoning_content", pendingReasoningContent) pendingReasoningContent = "" + pendingReasoningContentSet = false } } @@ -227,17 +234,21 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu case "reasoning": if summary := item.Get("summary"); summary.Exists() && summary.IsArray() { var textParts []string + hasSummaryText := false summary.ForEach(func(_, s gjson.Result) bool { if t := s.Get("text"); t.Exists() { + hasSummaryText = true textParts = append(textParts, t.String()) } return true }) - if len(textParts) > 0 { + if hasSummaryText { pendingReasoningContent = strings.Join(textParts, "") + pendingReasoningContentSet = true } - } else if ec := item.Get("encrypted_content"); ec.Exists() && ec.String() != "" { + } else if ec := item.Get("encrypted_content"); ec.Exists() { pendingReasoningContent = ec.String() + pendingReasoningContentSet = true } } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index 774ba063ef1..e0d86638e8b 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -230,6 +230,77 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemSumma } } +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningBeforeFunctionCall(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_abc","summary":[{"type":"summary_text","text":"I need to call a tool"}]}, + {"type":"function_call","call_id":"call_1","name":"search","arguments":"{\"q\":\"test\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"result"} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, true) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist when reasoning precedes function_call") + } + if rc.String() != "I need to call a tool" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "I need to call a tool") + } + + if got := gjson.GetBytes(out, "messages.1.role").String(); got != "assistant" { + t.Fatalf("messages.1.role = %q, want %q", got, "assistant") + } + if got := len(gjson.GetBytes(out, "messages.1.tool_calls").Array()); got != 1 { + t.Fatalf("messages.1.tool_calls length = %d, want %d", got, 1) + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningBeforeFunctionCallEncrypted(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_abc","encrypted_content":"enc_data"}, + {"type":"function_call","call_id":"call_1","name":"search","arguments":"{\"q\":\"test\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"result"} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false) + t.Logf("output json:\n%s", prettyJSONForTest(out)) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist from encrypted_content when reasoning precedes function_call") + } + if rc.String() != "enc_data" { + t.Fatalf("messages.1.reasoning_content = %q, want %q", rc.String(), "enc_data") + } +} + +func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemEmptySummaryText(t *testing.T) { + raw := []byte(`{ + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","id":"rs_empty","summary":[{"type":"summary_text","text":""}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]} + ] + }`) + + out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-r1", raw, false) + + rc := gjson.GetBytes(out, "messages.1.reasoning_content") + if !rc.Exists() { + t.Fatalf("messages.1.reasoning_content should exist for empty summary text") + } + if rc.String() != "" { + t.Fatalf("messages.1.reasoning_content = %q, want empty string", rc.String()) + } +} + func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReasoningItemFallsBackToMessageRC(t *testing.T) { raw := []byte(`{ "input": [ diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 8895b684452..bc50a6e2fa7 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -717,9 +717,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co // Build output list from choices[...] outputsWrapper := []byte(`{"arr":[]}`) - // Detect and capture reasoning content if present - rcText := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content").String() - includeReasoning := rcText != "" + // Detect and capture reasoning content if present. + rcNode := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content") + rcText := rcNode.String() + includeReasoning := rcNode.Exists() if !includeReasoning && len(requestRawJSON) > 0 { includeReasoning = gjson.GetBytes(requestRawJSON, "reasoning").Exists() } @@ -731,7 +732,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co // Prefer summary_text from reasoning_content; encrypted_content is optional reasoningItem := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) reasoningItem, _ = sjson.SetBytes(reasoningItem, "id", fmt.Sprintf("rs_%s", rid)) - if rcText != "" { + if rcNode.Exists() { reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.type", "summary_text") reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rcText) } diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index cafcacb7280..c4680687519 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -421,3 +421,55 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_FunctionCallDoneA t.Fatalf("unexpected completed function_call order: %v", completedOrder) } } + +func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_PreservesEmptyReasoningContent(t *testing.T) { + request := []byte(`{"model":"deepseek-r1","reasoning":{"effort":"medium"}}`) + raw := []byte(`{ + "id":"resp_empty_reasoning", + "object":"chat.completion", + "created":1773896263, + "model":"deepseek-r1", + "choices":[ + { + "index":0, + "message":{ + "role":"assistant", + "content":"answer", + "reasoning_content":"" + }, + "finish_reason":"stop" + } + ], + "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2} + }`) + + out := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "deepseek-r1", request, request, raw, nil) + + output := gjson.GetBytes(out, "output") + if !output.Exists() || !output.IsArray() { + t.Fatalf("output should be an array") + } + if got := len(output.Array()); got != 2 { + t.Fatalf("output length = %d, want %d", got, 2) + } + + reasoning := output.Array()[0] + if got := reasoning.Get("type").String(); got != "reasoning" { + t.Fatalf("output[0].type = %q, want %q", got, "reasoning") + } + summary := reasoning.Get("summary") + if !summary.Exists() || !summary.IsArray() || len(summary.Array()) != 1 { + t.Fatalf("reasoning summary should contain one empty summary_text item") + } + if got := summary.Get("0.type").String(); got != "summary_text" { + t.Fatalf("summary[0].type = %q, want %q", got, "summary_text") + } + if got := summary.Get("0.text").String(); got != "" { + t.Fatalf("summary[0].text = %q, want empty string", got) + } + + message := output.Array()[1] + if got := message.Get("type").String(); got != "message" { + t.Fatalf("output[1].type = %q, want %q", got, "message") + } +}