From e0f5ccff2d2e12e301a3bcda10307cf306709fc6 Mon Sep 17 00:00:00 2001 From: cj Date: Fri, 1 May 2026 05:15:31 +0000 Subject: [PATCH 1/3] fix(openai_compat): parse reasoning_content in streaming responses Streaming SSE responses from OpenAI-compatible providers (e.g. DeepSeek thinking-mode) can include reasoning_content deltas alongside content and tool_calls. Previously the parseStreamResponse Delta struct ignored reasoning_content, so assistant reasoning was silently lost. Fix: add ReasoningContent to the stream Delta struct, accumulate it in parallel with content, and include it in the returned LLMResponse. This intentionally does NOT change the existing DeepSeek replay rules in filterDeepSeekReasoningTurn. Those rules correctly enforce the documented vendor contract: reasoning_content must be preserved for tool-interaction turns but is optional (ignored) for plain assistant turns. Test: TestProviderChatStream_ParsesReasoningContent covers SSE streaming with interleaved reasoning_content, content, tool call deltas, and usage. --- pkg/providers/openai_compat/provider.go | 18 +++++-- pkg/providers/openai_compat/provider_test.go | 51 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index be3e77a43..0dbeba0b6 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -419,6 +419,7 @@ func parseStreamResponse( onChunk func(accumulated string), ) (*LLMResponse, error) { var textContent strings.Builder + var reasoningContent strings.Builder var finishReason string var usage *UsageInfo @@ -451,7 +452,8 @@ func parseStreamResponse( var chunk struct { Choices []struct { Delta struct { - Content string `json:"content"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` ToolCalls []struct { Index int `json:"index"` ID string `json:"id"` @@ -488,6 +490,11 @@ func parseStreamResponse( } } + // Accumulate reasoning content (e.g. DeepSeek thinking-mode tokens) + if choice.Delta.ReasoningContent != "" { + reasoningContent.WriteString(choice.Delta.ReasoningContent) + } + // Accumulate tool call deltas for _, tc := range choice.Delta.ToolCalls { acc, ok := activeTools[tc.Index] @@ -544,10 +551,11 @@ func parseStreamResponse( } return &LLMResponse{ - Content: textContent.String(), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, + Content: textContent.String(), + ReasoningContent: reasoningContent.String(), + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: usage, }, nil } diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 4f68fb393..fbda447b4 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -1195,6 +1195,57 @@ func TestProviderChatStream_CustomHeadersInjected(t *testing.T) { } } +func TestProviderChatStream_ParsesReasoningContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte( + "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"Let me \",\"content\":\"Checking \",\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\"}}]}}]}\n\n", + )) + _, _ = w.Write([]byte( + "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think step by step.\",\"content\":\"the weather\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"Hangzhou\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":6,\"total_tokens\":16}}\n\n", + )) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.ChatStream( + t.Context(), + []Message{{Role: "user", Content: "weather?"}}, + nil, + "deepseek-v4-flash", + nil, + nil, + ) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + if out.Content != "Checking the weather" { + t.Fatalf("Content = %q, want %q", out.Content, "Checking the weather") + } + if out.ReasoningContent != "Let me think step by step." { + t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step.") + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ID != "call_1" { + t.Fatalf("ToolCalls[0].ID = %q, want %q", out.ToolCalls[0].ID, "call_1") + } + if out.ToolCalls[0].Name != "get_weather" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.ToolCalls[0].Arguments["city"] != "Hangzhou" { + t.Fatalf("ToolCalls[0].Arguments[city] = %v, want %q", out.ToolCalls[0].Arguments["city"], "Hangzhou") + } + if out.FinishReason != "tool_calls" { + t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "tool_calls") + } + if out.Usage == nil || out.Usage.TotalTokens != 16 { + t.Fatalf("Usage = %#v, want total tokens 16", out.Usage) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { From b4aa40760141d3befdb10a7daabea4c246302573 Mon Sep 17 00:00:00 2001 From: cj Date: Sat, 2 May 2026 03:28:19 +0000 Subject: [PATCH 2/3] fix(openai_compat): preserve reasoning_content for all DeepSeek assistant turns Remove the hasToolInteraction guard in filterDeepSeekReasoningTurn that was stripping reasoning_content from non-tool interaction assistant messages. DeepSeek V3/V4+ thinking mode rejects requests where any assistant message is missing reasoning_content, returning 400: "The reasoning_content in the thinking mode must be passed back to the API." This is a follow-up to the streaming fix that captured reasoning_content from SSE deltas. That fix alone wasn't sufficient because reasoning_content was still being stripped on replay for plain assistant turns. --- pkg/providers/openai_compat/provider.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 0dbeba0b6..ce6e56b37 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -255,14 +255,6 @@ func filterDeepSeekReasoningMessages(messages []Message) []Message { } func filterDeepSeekReasoningTurn(messages []Message) []Message { - hasToolInteraction := false - for _, msg := range messages { - if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) { - hasToolInteraction = true - break - } - } - out := make([]Message, 0, len(messages)) for _, msg := range messages { if messageutil.IsTransientAssistantThoughtMessage(msg) { @@ -270,13 +262,10 @@ func filterDeepSeekReasoningTurn(messages []Message) []Message { } cloned := msg - // DeepSeek thinking-mode replay only requires reasoning_content for - // turns that participate in a tool interaction round. For plain - // assistant turns between two user messages, the docs say the API will - // ignore reasoning_content on replay, so we strip it here. - if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction { - cloned.ReasoningContent = "" - } + // DeepSeek thinking-mode requires reasoning_content to be echoed + // back for ALL assistant turns. If missing the API returns 400: + // "The reasoning_content in the thinking mode must be passed back + // to the API." if assistantMessageEmpty(cloned) { continue } From 3a8e1f69521b135c18e5ed0eaaaed095bbde57d6 Mon Sep 17 00:00:00 2001 From: cj Date: Sun, 3 May 2026 01:55:48 +0000 Subject: [PATCH 3/3] Revert: restore hasToolInteraction guard in filterDeepSeekReasoningTurn Restore the tool-interaction check that was removed in the previous commit. DeepSeek's documented behavior only requires reasoning_content to be preserved for tool-interaction turns; plain assistant turns between user messages do not need it on replay. This reverts the production code change while keeping the streaming reasoning_content parsing fix from the earlier commit intact. --- pkg/providers/openai_compat/provider.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index ce6e56b37..0dbeba0b6 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -255,6 +255,14 @@ func filterDeepSeekReasoningMessages(messages []Message) []Message { } func filterDeepSeekReasoningTurn(messages []Message) []Message { + hasToolInteraction := false + for _, msg := range messages { + if msg.Role == "tool" || (msg.Role == "assistant" && len(msg.ToolCalls) > 0) { + hasToolInteraction = true + break + } + } + out := make([]Message, 0, len(messages)) for _, msg := range messages { if messageutil.IsTransientAssistantThoughtMessage(msg) { @@ -262,10 +270,13 @@ func filterDeepSeekReasoningTurn(messages []Message) []Message { } cloned := msg - // DeepSeek thinking-mode requires reasoning_content to be echoed - // back for ALL assistant turns. If missing the API returns 400: - // "The reasoning_content in the thinking mode must be passed back - // to the API." + // DeepSeek thinking-mode replay only requires reasoning_content for + // turns that participate in a tool interaction round. For plain + // assistant turns between two user messages, the docs say the API will + // ignore reasoning_content on replay, so we strip it here. + if cloned.Role == "assistant" && strings.TrimSpace(cloned.ReasoningContent) != "" && !hasToolInteraction { + cloned.ReasoningContent = "" + } if assistantMessageEmpty(cloned) { continue }