From e0f5ccff2d2e12e301a3bcda10307cf306709fc6 Mon Sep 17 00:00:00 2001 From: cj Date: Fri, 1 May 2026 05:15:31 +0000 Subject: [PATCH] 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) {