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:
parent
6e1fab80e2
commit
e0f5ccff2d
2 changed files with 64 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue