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.
This commit is contained in:
cj 2026-05-01 05:15:31 +00:00
parent 6e1fab80e2
commit e0f5ccff2d
2 changed files with 64 additions and 5 deletions

View file

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

View file

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