Skip to content

Commit 77d0cf1

Browse files
author
Elizabeth
committed
fix: preserve reasoning_content for DeepSeek chat completions
DeepSeek V4 requires reasoning_content to be passed back unchanged in assistant messages on multi-turn conversations. Previously it was dropped at every stage: not captured from the stream delta, not stored, and not re-serialized in follow-up requests, causing HTTP 400 on the second API call.
1 parent 263eda4 commit 77d0cf1

3 files changed

Lines changed: 206 additions & 24 deletions

File tree

pkg/aiusechat/openaichat/openaichat-backend.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ func processChatStream(
101101
) (*uctypes.WaveStopReason, *StoredChatMessage, error) {
102102
decoder := eventsource.NewDecoder(body)
103103
var textBuilder strings.Builder
104+
var reasoningBuilder strings.Builder
105+
reasoningStarted := false
104106
msgID := uuid.New().String()
105107
textID := uuid.New().String()
106108
var finishReason string
@@ -128,7 +130,7 @@ func processChatStream(
128130
break
129131
}
130132
if sseHandler.Err() != nil {
131-
partialMsg := extractPartialTextMessage(msgID, textBuilder.String())
133+
partialMsg := extractPartialTextMessage(msgID, textBuilder.String(), reasoningBuilder.String())
132134
return &uctypes.WaveStopReason{
133135
Kind: uctypes.StopKindCanceled,
134136
ErrorType: "client_disconnect",
@@ -168,6 +170,15 @@ func processChatStream(
168170
_ = sseHandler.AiMsgTextDelta(textID, choice.Delta.Content)
169171
}
170172

173+
if choice.Delta.ReasoningContent != "" {
174+
if !reasoningStarted {
175+
reasoningStarted = true
176+
_ = sseHandler.AiMsgReasoningStart(msgID)
177+
}
178+
reasoningBuilder.WriteString(choice.Delta.ReasoningContent)
179+
_ = sseHandler.AiMsgReasoningDelta(msgID, choice.Delta.ReasoningContent)
180+
}
181+
171182
if len(choice.Delta.ToolCalls) > 0 {
172183
for _, tcDelta := range choice.Delta.ToolCalls {
173184
idx := tcDelta.Index
@@ -239,7 +250,8 @@ func processChatStream(
239250
assistantMsg := &StoredChatMessage{
240251
MessageId: msgID,
241252
Message: ChatRequestMessage{
242-
Role: "assistant",
253+
Role: "assistant",
254+
ReasoningContent: reasoningBuilder.String(),
243255
},
244256
}
245257

@@ -252,6 +264,9 @@ func processChatStream(
252264
if textStarted {
253265
_ = sseHandler.AiMsgTextEnd(textID)
254266
}
267+
if reasoningStarted {
268+
_ = sseHandler.AiMsgReasoningEnd(msgID)
269+
}
255270
_ = sseHandler.AiMsgFinishStep()
256271
if stopKind != uctypes.StopKindToolUse {
257272
_ = sseHandler.AiMsgFinish(finishReason, nil)
@@ -260,16 +275,17 @@ func processChatStream(
260275
return stopReason, assistantMsg, nil
261276
}
262277

263-
func extractPartialTextMessage(msgID string, text string) *StoredChatMessage {
264-
if text == "" {
278+
func extractPartialTextMessage(msgID string, text string, reasoning string) *StoredChatMessage {
279+
if text == "" && reasoning == "" {
265280
return nil
266281
}
267282

268283
return &StoredChatMessage{
269284
MessageId: msgID,
270285
Message: ChatRequestMessage{
271-
Role: "assistant",
272-
Content: text,
286+
Role: "assistant",
287+
Content: text,
288+
ReasoningContent: reasoning,
273289
},
274290
}
275291
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package openaichat
5+
6+
import (
7+
"encoding/json"
8+
"strings"
9+
"testing"
10+
11+
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
12+
)
13+
14+
func TestReasoningContentRoundTrip(t *testing.T) {
15+
original := ChatRequestMessage{
16+
Role: "assistant",
17+
Content: "The answer is 42.",
18+
ReasoningContent: "Let me think about this carefully...",
19+
ToolCalls: []ToolCall{
20+
{ID: "call_1", Type: "function", Function: ToolFunctionCall{Name: "search", Arguments: `{}`}},
21+
},
22+
}
23+
24+
data, err := json.Marshal(original)
25+
if err != nil {
26+
t.Fatalf("Marshal failed: %v", err)
27+
}
28+
29+
var restored ChatRequestMessage
30+
if err := json.Unmarshal(data, &restored); err != nil {
31+
t.Fatalf("Unmarshal failed: %v", err)
32+
}
33+
34+
if restored.Role != original.Role {
35+
t.Errorf("Role: got %q, want %q", restored.Role, original.Role)
36+
}
37+
if restored.Content != original.Content {
38+
t.Errorf("Content: got %q, want %q", restored.Content, original.Content)
39+
}
40+
if restored.ReasoningContent != original.ReasoningContent {
41+
t.Errorf("ReasoningContent: got %q, want %q", restored.ReasoningContent, original.ReasoningContent)
42+
}
43+
if len(restored.ToolCalls) != len(original.ToolCalls) {
44+
t.Fatalf("ToolCalls length: got %d, want %d", len(restored.ToolCalls), len(original.ToolCalls))
45+
}
46+
if restored.ToolCalls[0].ID != original.ToolCalls[0].ID {
47+
t.Errorf("ToolCalls[0].ID: got %q, want %q", restored.ToolCalls[0].ID, original.ToolCalls[0].ID)
48+
}
49+
if restored.ToolCalls[0].Function.Name != original.ToolCalls[0].Function.Name {
50+
t.Errorf("ToolCalls[0].Function.Name: got %q, want %q", restored.ToolCalls[0].Function.Name, original.ToolCalls[0].Function.Name)
51+
}
52+
}
53+
54+
func TestReasoningContentOmittedWhenEmpty(t *testing.T) {
55+
msg := ChatRequestMessage{
56+
Role: "user",
57+
Content: "Hello",
58+
}
59+
60+
data, err := json.Marshal(msg)
61+
if err != nil {
62+
t.Fatalf("Marshal failed: %v", err)
63+
}
64+
65+
jsonStr := string(data)
66+
if strings.Contains(jsonStr, "reasoning_content") {
67+
t.Errorf("JSON should NOT contain 'reasoning_content' when empty, got: %s", jsonStr)
68+
}
69+
}
70+
71+
func TestStreamChunkWithReasoningContent(t *testing.T) {
72+
chunkJSON := `{"choices":[{"delta":{"reasoning_content":"I need to search for this...","content":"Let me search."}}]}`
73+
74+
var chunk StreamChunk
75+
if err := json.Unmarshal([]byte(chunkJSON), &chunk); err != nil {
76+
t.Fatalf("Unmarshal failed: %v", err)
77+
}
78+
79+
if len(chunk.Choices) == 0 {
80+
t.Fatal("expected at least one choice")
81+
}
82+
83+
delta := chunk.Choices[0].Delta
84+
if delta.ReasoningContent != "I need to search for this..." {
85+
t.Errorf("ReasoningContent: got %q, want %q", delta.ReasoningContent, "I need to search for this...")
86+
}
87+
if delta.Content != "Let me search." {
88+
t.Errorf("Content: got %q, want %q", delta.Content, "Let me search.")
89+
}
90+
}
91+
92+
func TestCleanPreservesReasoningContent(t *testing.T) {
93+
msg := &ChatRequestMessage{
94+
Role: "assistant",
95+
Content: "text",
96+
ReasoningContent: "thinking",
97+
ToolCalls: []ToolCall{
98+
{ID: "call_1", Type: "function", Function: ToolFunctionCall{Name: "f", Arguments: "{}"}, ToolUseData: &uctypes.UIMessageDataToolUse{}},
99+
},
100+
}
101+
102+
cleaned := msg.clean()
103+
104+
if cleaned == msg {
105+
t.Error("clean() should return a different pointer")
106+
}
107+
108+
if cleaned.ReasoningContent != "thinking" {
109+
t.Errorf("ReasoningContent: got %q, want %q", cleaned.ReasoningContent, "thinking")
110+
}
111+
112+
if cleaned.Content != "text" {
113+
t.Errorf("Content: got %q, want %q", cleaned.Content, "text")
114+
}
115+
116+
if len(cleaned.ToolCalls) != 1 {
117+
t.Fatalf("ToolCalls length: got %d, want 1", len(cleaned.ToolCalls))
118+
}
119+
if cleaned.ToolCalls[0].ToolUseData != nil {
120+
t.Error("ToolCalls[0].ToolUseData should be nil after clean()")
121+
}
122+
}
123+
124+
func TestExtractPartialTextMessageWithReasoning(t *testing.T) {
125+
msg := extractPartialTextMessage("msg-1", "partial text", "partial reasoning")
126+
if msg == nil {
127+
t.Fatal("expected non-nil message when text is present")
128+
}
129+
if msg.MessageId != "msg-1" {
130+
t.Errorf("MessageId: got %q, want %q", msg.MessageId, "msg-1")
131+
}
132+
if msg.Message.Content != "partial text" {
133+
t.Errorf("Content: got %q, want %q", msg.Message.Content, "partial text")
134+
}
135+
if msg.Message.ReasoningContent != "partial reasoning" {
136+
t.Errorf("ReasoningContent: got %q, want %q", msg.Message.ReasoningContent, "partial reasoning")
137+
}
138+
if msg.Message.Role != "assistant" {
139+
t.Errorf("Role: got %q, want %q", msg.Message.Role, "assistant")
140+
}
141+
}
142+
143+
func TestExtractPartialTextMessageWithOnlyReasoning(t *testing.T) {
144+
msg := extractPartialTextMessage("msg-2", "", "some reasoning")
145+
if msg == nil {
146+
t.Fatal("expected non-nil message when reasoning is present")
147+
}
148+
if msg.Message.Content != "" {
149+
t.Errorf("Content: got %q, want empty", msg.Message.Content)
150+
}
151+
if msg.Message.ReasoningContent != "some reasoning" {
152+
t.Errorf("ReasoningContent: got %q, want %q", msg.Message.ReasoningContent, "some reasoning")
153+
}
154+
}
155+
156+
func TestExtractPartialTextMessageEmpty(t *testing.T) {
157+
msg := extractPartialTextMessage("msg-3", "", "")
158+
if msg != nil {
159+
t.Fatal("expected nil when both text and reasoning are empty")
160+
}
161+
}

pkg/aiusechat/openaichat/openaichat-types.go

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,29 +50,32 @@ type ChatImageUrl struct {
5050
}
5151

5252
type ChatRequestMessage struct {
53-
Role string `json:"role"` // "system","user","assistant","tool"
54-
Content string `json:"-"` // plain text (used when ContentParts is nil)
55-
ContentParts []ChatContentPart `json:"-"` // multimodal parts (used when images present)
56-
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant tool-call message
57-
ToolCallID string `json:"tool_call_id,omitempty"` // for role:"tool"
58-
Name string `json:"name,omitempty"` // tool name on role:"tool"
53+
Role string `json:"role"` // "system","user","assistant","tool"
54+
ReasoningContent string `json:"-"` // DeepSeek/OpenAI reasoning_content (top-level string)
55+
Content string `json:"-"` // plain text (used when ContentParts is nil)
56+
ContentParts []ChatContentPart `json:"-"` // multimodal parts (used when images present)
57+
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant tool-call message
58+
ToolCallID string `json:"tool_call_id,omitempty"` // for role:"tool"
59+
Name string `json:"name,omitempty"` // tool name on role:"tool"
5960
}
6061

6162
// chatRequestMessageJSON is the wire format for ChatRequestMessage
6263
type chatRequestMessageJSON struct {
63-
Role string `json:"role"`
64-
Content json.RawMessage `json:"content"`
65-
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
66-
ToolCallID string `json:"tool_call_id,omitempty"`
67-
Name string `json:"name,omitempty"`
64+
Role string `json:"role"`
65+
ReasoningContent string `json:"reasoning_content,omitempty"`
66+
Content json.RawMessage `json:"content"`
67+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
68+
ToolCallID string `json:"tool_call_id,omitempty"`
69+
Name string `json:"name,omitempty"`
6870
}
6971

7072
func (cm ChatRequestMessage) MarshalJSON() ([]byte, error) {
7173
raw := chatRequestMessageJSON{
72-
Role: cm.Role,
73-
ToolCalls: cm.ToolCalls,
74-
ToolCallID: cm.ToolCallID,
75-
Name: cm.Name,
74+
Role: cm.Role,
75+
ReasoningContent: cm.ReasoningContent,
76+
ToolCalls: cm.ToolCalls,
77+
ToolCallID: cm.ToolCallID,
78+
Name: cm.Name,
7679
}
7780
if len(cm.ContentParts) > 0 {
7881
b, err := json.Marshal(cm.ContentParts)
@@ -96,6 +99,7 @@ func (cm *ChatRequestMessage) UnmarshalJSON(data []byte) error {
9699
return err
97100
}
98101
cm.Role = raw.Role
102+
cm.ReasoningContent = raw.ReasoningContent
99103
cm.ToolCalls = raw.ToolCalls
100104
cm.ToolCallID = raw.ToolCallID
101105
cm.Name = raw.Name
@@ -193,9 +197,10 @@ type StreamChoice struct {
193197

194198
// This is the important part:
195199
type ContentDelta struct {
196-
Role string `json:"role,omitempty"`
197-
Content string `json:"content,omitempty"`
198-
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
200+
Role string `json:"role,omitempty"`
201+
Content string `json:"content,omitempty"`
202+
ReasoningContent string `json:"reasoning_content,omitempty"`
203+
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
199204
}
200205

201206
type ToolCallDelta struct {

0 commit comments

Comments
 (0)