Skip to content

Commit a0da263

Browse files
committed
feat: add support for reasoning field in chat completions
- Support both reasoning_content (DeepSeek style) and reasoning (OpenAI style) - Add custom UnmarshalJSON to handle field mapping transparently - Add comprehensive tests for reasoning field support - Maintain backward compatibility with existing code
1 parent 5d7a276 commit a0da263

File tree

3 files changed

+288
-10
lines changed

3 files changed

+288
-10
lines changed

chat.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,9 @@ type ChatCompletionMessage struct {
106106
// - https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
107107
Name string `json:"name,omitempty"`
108108

109-
// This property is used for the "reasoning" feature supported by deepseek-reasoner
110-
// which is not in the official documentation.
111-
// the doc from deepseek:
112-
// - https://api-docs.deepseek.com/api/create-chat-completion#responses
109+
// This property is used for the "reasoning" feature supported by reasoning models.
110+
// Supports both reasoning_content (DeepSeek style) and reasoning (OpenAI style) fields.
111+
// DeepSeek doc: https://api-docs.deepseek.com/api/create-chat-completion#responses
113112
ReasoningContent string `json:"reasoning_content,omitempty"`
114113

115114
FunctionCall *FunctionCall `json:"function_call,omitempty"`
@@ -162,13 +161,28 @@ func (m *ChatCompletionMessage) UnmarshalJSON(bs []byte) error {
162161
MultiContent []ChatMessagePart
163162
Name string `json:"name,omitempty"`
164163
ReasoningContent string `json:"reasoning_content,omitempty"`
164+
Reasoning string `json:"reasoning,omitempty"`
165165
FunctionCall *FunctionCall `json:"function_call,omitempty"`
166166
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
167167
ToolCallID string `json:"tool_call_id,omitempty"`
168168
}{}
169169

170170
if err := json.Unmarshal(bs, &msg); err == nil {
171-
*m = ChatCompletionMessage(msg)
171+
*m = ChatCompletionMessage{
172+
Role: msg.Role,
173+
Content: msg.Content,
174+
Refusal: msg.Refusal,
175+
MultiContent: msg.MultiContent,
176+
Name: msg.Name,
177+
ReasoningContent: msg.ReasoningContent,
178+
FunctionCall: msg.FunctionCall,
179+
ToolCalls: msg.ToolCalls,
180+
ToolCallID: msg.ToolCallID,
181+
}
182+
// Fallback to reasoning field if reasoning_content is empty
183+
if m.ReasoningContent == "" && msg.Reasoning != "" {
184+
m.ReasoningContent = msg.Reasoning
185+
}
172186
return nil
173187
}
174188
multiMsg := struct {
@@ -178,14 +192,29 @@ func (m *ChatCompletionMessage) UnmarshalJSON(bs []byte) error {
178192
MultiContent []ChatMessagePart `json:"content"`
179193
Name string `json:"name,omitempty"`
180194
ReasoningContent string `json:"reasoning_content,omitempty"`
195+
Reasoning string `json:"reasoning,omitempty"`
181196
FunctionCall *FunctionCall `json:"function_call,omitempty"`
182197
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
183198
ToolCallID string `json:"tool_call_id,omitempty"`
184199
}{}
185200
if err := json.Unmarshal(bs, &multiMsg); err != nil {
186201
return err
187202
}
188-
*m = ChatCompletionMessage(multiMsg)
203+
*m = ChatCompletionMessage{
204+
Role: multiMsg.Role,
205+
Content: multiMsg.Content,
206+
Refusal: multiMsg.Refusal,
207+
MultiContent: multiMsg.MultiContent,
208+
Name: multiMsg.Name,
209+
ReasoningContent: multiMsg.ReasoningContent,
210+
FunctionCall: multiMsg.FunctionCall,
211+
ToolCalls: multiMsg.ToolCalls,
212+
ToolCallID: multiMsg.ToolCallID,
213+
}
214+
// Fallback to reasoning field if reasoning_content is empty
215+
if m.ReasoningContent == "" && multiMsg.Reasoning != "" {
216+
m.ReasoningContent = multiMsg.Reasoning
217+
}
189218
return nil
190219
}
191220

chat_reasoning_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package openai_test
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/sashabaranov/go-openai"
8+
)
9+
10+
// TestChatCompletionStreamChoiceDelta_ReasoningFieldSupport tests that both
11+
// reasoning_content and reasoning fields are properly supported in streaming responses.
12+
func TestChatCompletionStreamChoiceDelta_ReasoningFieldSupport(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
jsonData string
16+
expected string
17+
}{
18+
{
19+
name: "DeepSeek style - reasoning_content",
20+
jsonData: `{"role":"assistant","content":"Hello","reasoning_content":"This is my reasoning"}`,
21+
expected: "This is my reasoning",
22+
},
23+
{
24+
name: "OpenAI style - reasoning",
25+
jsonData: `{"role":"assistant","content":"Hello","reasoning":"This is my reasoning"}`,
26+
expected: "This is my reasoning",
27+
},
28+
{
29+
name: "Both fields present - reasoning_content takes priority",
30+
jsonData: `{"role":"assistant","content":"Hello","reasoning_content":"Priority reasoning","reasoning":"Fallback reasoning"}`,
31+
expected: "Priority reasoning",
32+
},
33+
{
34+
name: "Only reasoning field",
35+
jsonData: `{"role":"assistant","reasoning":"Only reasoning field"}`,
36+
expected: "Only reasoning field",
37+
},
38+
{
39+
name: "No reasoning fields",
40+
jsonData: `{"role":"assistant","content":"Hello"}`,
41+
expected: "",
42+
},
43+
}
44+
45+
for _, tt := range tests {
46+
t.Run(tt.name, func(t *testing.T) {
47+
var delta openai.ChatCompletionStreamChoiceDelta
48+
err := json.Unmarshal([]byte(tt.jsonData), &delta)
49+
if err != nil {
50+
t.Fatalf("Failed to unmarshal JSON: %v", err)
51+
}
52+
53+
if delta.ReasoningContent != tt.expected {
54+
t.Errorf("Expected ReasoningContent to be %q, got %q", tt.expected, delta.ReasoningContent)
55+
}
56+
})
57+
}
58+
}
59+
60+
// TestChatCompletionMessage_ReasoningFieldSupport tests that both
61+
// reasoning_content and reasoning fields are properly supported in chat completion messages.
62+
func TestChatCompletionMessage_ReasoningFieldSupport(t *testing.T) {
63+
tests := []struct {
64+
name string
65+
jsonData string
66+
expected string
67+
}{
68+
{
69+
name: "DeepSeek style - reasoning_content",
70+
jsonData: `{"role":"assistant","content":"Hello","reasoning_content":"This is my reasoning"}`,
71+
expected: "This is my reasoning",
72+
},
73+
{
74+
name: "OpenAI style - reasoning",
75+
jsonData: `{"role":"assistant","content":"Hello","reasoning":"This is my reasoning"}`,
76+
expected: "This is my reasoning",
77+
},
78+
{
79+
name: "Both fields present - reasoning_content takes priority",
80+
jsonData: `{"role":"assistant","content":"Hello","reasoning_content":"Priority reasoning","reasoning":"Fallback reasoning"}`,
81+
expected: "Priority reasoning",
82+
},
83+
{
84+
name: "Only reasoning field",
85+
jsonData: `{"role":"assistant","reasoning":"Only reasoning field"}`,
86+
expected: "Only reasoning field",
87+
},
88+
{
89+
name: "No reasoning fields",
90+
jsonData: `{"role":"assistant","content":"Hello"}`,
91+
expected: "",
92+
},
93+
}
94+
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
var msg openai.ChatCompletionMessage
98+
err := json.Unmarshal([]byte(tt.jsonData), &msg)
99+
if err != nil {
100+
t.Fatalf("Failed to unmarshal JSON: %v", err)
101+
}
102+
103+
if msg.ReasoningContent != tt.expected {
104+
t.Errorf("Expected ReasoningContent to be %q, got %q", tt.expected, msg.ReasoningContent)
105+
}
106+
})
107+
}
108+
}
109+
110+
// TestChatCompletionMessage_MultiContent_ReasoningFieldSupport tests reasoning field support
111+
// with MultiContent messages.
112+
func TestChatCompletionMessage_MultiContent_ReasoningFieldSupport(t *testing.T) {
113+
tests := []struct {
114+
name string
115+
jsonData string
116+
expected string
117+
}{
118+
{
119+
name: "MultiContent with reasoning_content",
120+
jsonData: `{"role":"assistant","content":[{"type":"text","text":"Hello"}],"reasoning_content":"Multi reasoning"}`,
121+
expected: "Multi reasoning",
122+
},
123+
{
124+
name: "MultiContent with reasoning",
125+
jsonData: `{"role":"assistant","content":[{"type":"text","text":"Hello"}],"reasoning":"Multi reasoning"}`,
126+
expected: "Multi reasoning",
127+
},
128+
}
129+
130+
for _, tt := range tests {
131+
t.Run(tt.name, func(t *testing.T) {
132+
var msg openai.ChatCompletionMessage
133+
err := json.Unmarshal([]byte(tt.jsonData), &msg)
134+
if err != nil {
135+
t.Fatalf("Failed to unmarshal JSON: %v", err)
136+
}
137+
138+
if msg.ReasoningContent != tt.expected {
139+
t.Errorf("Expected ReasoningContent to be %q, got %q", tt.expected, msg.ReasoningContent)
140+
}
141+
})
142+
}
143+
}
144+
145+
// TestChatCompletionStreamChoiceDelta_MarshalJSON tests that marshaling preserves
146+
// the reasoning_content field name.
147+
func TestChatCompletionStreamChoiceDelta_MarshalJSON(t *testing.T) {
148+
delta := openai.ChatCompletionStreamChoiceDelta{
149+
Role: "assistant",
150+
Content: "Hello",
151+
ReasoningContent: "Test reasoning",
152+
}
153+
154+
data, err := json.Marshal(delta)
155+
if err != nil {
156+
t.Fatalf("Failed to marshal delta: %v", err)
157+
}
158+
159+
// Verify that it's marshaled as reasoning_content
160+
var result map[string]interface{}
161+
err = json.Unmarshal(data, &result)
162+
if err != nil {
163+
t.Fatalf("Failed to unmarshal result: %v", err)
164+
}
165+
166+
if _, hasReasoning := result["reasoning"]; hasReasoning {
167+
t.Error("Marshaled JSON should not contain 'reasoning' field")
168+
}
169+
170+
if reasoningContent, ok := result["reasoning_content"].(string); !ok || reasoningContent != "Test reasoning" {
171+
t.Errorf("Expected reasoning_content to be 'Test reasoning', got %v", result["reasoning_content"])
172+
}
173+
}
174+
175+
// TestRealWorldStreamingResponse tests parsing a real-world streaming response
176+
// with reasoning field (similar to the new_api_output.txt format).
177+
func TestRealWorldStreamingResponse(t *testing.T) {
178+
// Simulate a chunk from the new_api_output.txt file
179+
jsonData := `{
180+
"id":"gen-1763431956-test",
181+
"provider":"Azure",
182+
"model":"openai/gpt-5",
183+
"object":"chat.completion.chunk",
184+
"created":1763431956,
185+
"choices":[{
186+
"index":0,
187+
"delta":{
188+
"role":"assistant",
189+
"content":"",
190+
"reasoning":"quantum"
191+
},
192+
"finish_reason":null,
193+
"logprobs":null
194+
}]
195+
}`
196+
197+
var response openai.ChatCompletionStreamResponse
198+
err := json.Unmarshal([]byte(jsonData), &response)
199+
if err != nil {
200+
t.Fatalf("Failed to unmarshal streaming response: %v", err)
201+
}
202+
203+
if len(response.Choices) != 1 {
204+
t.Fatalf("Expected 1 choice, got %d", len(response.Choices))
205+
}
206+
207+
delta := response.Choices[0].Delta
208+
if delta.ReasoningContent != "quantum" {
209+
t.Errorf("Expected ReasoningContent to be 'quantum', got %q", delta.ReasoningContent)
210+
}
211+
212+
if delta.Role != "assistant" {
213+
t.Errorf("Expected Role to be 'assistant', got %q", delta.Role)
214+
}
215+
}

chat_stream.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package openai
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/http"
67
)
78

@@ -12,13 +13,46 @@ type ChatCompletionStreamChoiceDelta struct {
1213
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
1314
Refusal string `json:"refusal,omitempty"`
1415

15-
// This property is used for the "reasoning" feature supported by deepseek-reasoner
16-
// which is not in the official documentation.
17-
// the doc from deepseek:
18-
// - https://api-docs.deepseek.com/api/create-chat-completion#responses
16+
// This property is used for the "reasoning" feature supported by reasoning models.
17+
// Supports both reasoning_content (DeepSeek style) and reasoning (OpenAI style) fields.
18+
// DeepSeek doc: https://api-docs.deepseek.com/api/create-chat-completion#responses
1919
ReasoningContent string `json:"reasoning_content,omitempty"`
2020
}
2121

22+
// UnmarshalJSON custom unmarshaler to support both reasoning_content and reasoning fields.
23+
func (d *ChatCompletionStreamChoiceDelta) UnmarshalJSON(data []byte) error {
24+
type deltaAlias struct {
25+
Content string `json:"content,omitempty"`
26+
Role string `json:"role,omitempty"`
27+
FunctionCall *FunctionCall `json:"function_call,omitempty"`
28+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
29+
Refusal string `json:"refusal,omitempty"`
30+
ReasoningContent string `json:"reasoning_content,omitempty"`
31+
Reasoning string `json:"reasoning,omitempty"`
32+
}
33+
34+
var aux deltaAlias
35+
if err := json.Unmarshal(data, &aux); err != nil {
36+
return err
37+
}
38+
39+
*d = ChatCompletionStreamChoiceDelta{
40+
Content: aux.Content,
41+
Role: aux.Role,
42+
FunctionCall: aux.FunctionCall,
43+
ToolCalls: aux.ToolCalls,
44+
Refusal: aux.Refusal,
45+
ReasoningContent: aux.ReasoningContent,
46+
}
47+
48+
// Fallback to reasoning field if reasoning_content is empty
49+
if d.ReasoningContent == "" {
50+
d.ReasoningContent = aux.Reasoning
51+
}
52+
53+
return nil
54+
}
55+
2256
type ChatCompletionStreamChoiceLogprobs struct {
2357
Content []ChatCompletionTokenLogprob `json:"content,omitempty"`
2458
Refusal []ChatCompletionTokenLogprob `json:"refusal,omitempty"`

0 commit comments

Comments
 (0)