diff --git a/chat.go b/chat.go index 0aa018715..44b006a67 100644 --- a/chat.go +++ b/chat.go @@ -106,10 +106,9 @@ type ChatCompletionMessage struct { // - https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb Name string `json:"name,omitempty"` - // This property is used for the "reasoning" feature supported by deepseek-reasoner - // which is not in the official documentation. - // the doc from deepseek: - // - https://api-docs.deepseek.com/api/create-chat-completion#responses + // This property is used for the "reasoning" feature supported by reasoning models. + // Supports both reasoning_content (DeepSeek style) and reasoning (OpenAI style) fields. + // DeepSeek doc: https://api-docs.deepseek.com/api/create-chat-completion#responses ReasoningContent string `json:"reasoning_content,omitempty"` FunctionCall *FunctionCall `json:"function_call,omitempty"` @@ -162,13 +161,28 @@ func (m *ChatCompletionMessage) UnmarshalJSON(bs []byte) error { MultiContent []ChatMessagePart Name string `json:"name,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` FunctionCall *FunctionCall `json:"function_call,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` }{} if err := json.Unmarshal(bs, &msg); err == nil { - *m = ChatCompletionMessage(msg) + *m = ChatCompletionMessage{ + Role: msg.Role, + Content: msg.Content, + Refusal: msg.Refusal, + MultiContent: msg.MultiContent, + Name: msg.Name, + ReasoningContent: msg.ReasoningContent, + FunctionCall: msg.FunctionCall, + ToolCalls: msg.ToolCalls, + ToolCallID: msg.ToolCallID, + } + // Fallback to reasoning field if reasoning_content is empty + if m.ReasoningContent == "" && msg.Reasoning != "" { + m.ReasoningContent = msg.Reasoning + } return nil } multiMsg := struct { @@ -178,6 +192,7 @@ func (m *ChatCompletionMessage) UnmarshalJSON(bs []byte) error { MultiContent []ChatMessagePart `json:"content"` Name string `json:"name,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` FunctionCall *FunctionCall `json:"function_call,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` @@ -185,7 +200,21 @@ func (m *ChatCompletionMessage) UnmarshalJSON(bs []byte) error { if err := json.Unmarshal(bs, &multiMsg); err != nil { return err } - *m = ChatCompletionMessage(multiMsg) + *m = ChatCompletionMessage{ + Role: multiMsg.Role, + Content: multiMsg.Content, + Refusal: multiMsg.Refusal, + MultiContent: multiMsg.MultiContent, + Name: multiMsg.Name, + ReasoningContent: multiMsg.ReasoningContent, + FunctionCall: multiMsg.FunctionCall, + ToolCalls: multiMsg.ToolCalls, + ToolCallID: multiMsg.ToolCallID, + } + // Fallback to reasoning field if reasoning_content is empty + if m.ReasoningContent == "" && multiMsg.Reasoning != "" { + m.ReasoningContent = multiMsg.Reasoning + } return nil } diff --git a/chat_reasoning_test.go b/chat_reasoning_test.go new file mode 100644 index 000000000..356700523 --- /dev/null +++ b/chat_reasoning_test.go @@ -0,0 +1,294 @@ +package openai_test + +import ( + "encoding/json" + "testing" + + "github.com/sashabaranov/go-openai" +) + +// TestChatCompletionStreamChoiceDelta_ReasoningFieldSupport tests that both +// reasoning_content and reasoning fields are properly supported in streaming responses. +func TestChatCompletionStreamChoiceDelta_ReasoningFieldSupport(t *testing.T) { + tests := []struct { + name string + jsonData string + expected string + }{ + { + name: "DeepSeek style - reasoning_content", + jsonData: `{"role":"assistant","content":"Hello","reasoning_content":"This is my reasoning"}`, + expected: "This is my reasoning", + }, + { + name: "OpenAI style - reasoning", + jsonData: `{"role":"assistant","content":"Hello","reasoning":"This is my reasoning"}`, + expected: "This is my reasoning", + }, + { + name: "Both fields present - reasoning_content takes priority", + jsonData: `{"role":"assistant","content":"Hello",` + + `"reasoning_content":"Priority","reasoning":"Fallback"}`, + expected: "Priority", + }, + { + name: "Only reasoning field", + jsonData: `{"role":"assistant","reasoning":"Only reasoning field"}`, + expected: "Only reasoning field", + }, + { + name: "No reasoning fields", + jsonData: `{"role":"assistant","content":"Hello"}`, + expected: "", + }, + { + name: "Empty reasoning_content with reasoning value", + jsonData: `{"role":"assistant","reasoning_content":"","reasoning":"Fallback value"}`, + expected: "Fallback value", + }, + { + name: "Non-empty reasoning_content, empty reasoning", + jsonData: `{"role":"assistant","content":"Hello",` + + `"reasoning_content":"Primary","reasoning":""}`, + expected: "Primary", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var delta openai.ChatCompletionStreamChoiceDelta + err := json.Unmarshal([]byte(tt.jsonData), &delta) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if delta.ReasoningContent != tt.expected { + t.Errorf("Expected ReasoningContent to be %q, got %q", tt.expected, delta.ReasoningContent) + } + }) + } +} + +// TestChatCompletionStreamChoiceDelta_UnmarshalError tests error handling. +func TestChatCompletionStreamChoiceDelta_UnmarshalError(t *testing.T) { + var delta openai.ChatCompletionStreamChoiceDelta + invalidJSON := `{"role":"assistant","content":}` + err := json.Unmarshal([]byte(invalidJSON), &delta) + if err == nil { + t.Error("Expected error when unmarshaling invalid JSON, got nil") + } +} + +// TestChatCompletionStreamChoiceDelta_AllFields tests unmarshaling with all fields. +func TestChatCompletionStreamChoiceDelta_AllFields(t *testing.T) { + jsonData := `{ + "role":"assistant", + "content":"test content", + "refusal":"test refusal", + "reasoning":"test reasoning", + "function_call":{"name":"test_func","arguments":"{}"}, + "tool_calls":[{"id":"call_123","type":"function","function":{"name":"test","arguments":"{}"}}] + }` + var delta openai.ChatCompletionStreamChoiceDelta + err := json.Unmarshal([]byte(jsonData), &delta) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + if delta.Role != "assistant" { + t.Errorf("Expected Role to be 'assistant', got %q", delta.Role) + } + if delta.Content != "test content" { + t.Errorf("Expected Content to be 'test content', got %q", delta.Content) + } + if delta.Refusal != "test refusal" { + t.Errorf("Expected Refusal to be 'test refusal', got %q", delta.Refusal) + } + if delta.ReasoningContent != "test reasoning" { + t.Errorf("Expected ReasoningContent to be 'test reasoning', got %q", delta.ReasoningContent) + } + if delta.FunctionCall == nil || delta.FunctionCall.Name != "test_func" { + t.Error("Expected FunctionCall to be set") + } + if len(delta.ToolCalls) != 1 || delta.ToolCalls[0].ID != "call_123" { + t.Error("Expected ToolCalls to be set") + } +} + +// TestChatCompletionMessage_ReasoningFieldSupport tests that both +// reasoning_content and reasoning fields are properly supported in chat completion messages. +func TestChatCompletionMessage_ReasoningFieldSupport(t *testing.T) { + tests := []struct { + name string + jsonData string + expected string + }{ + { + name: "DeepSeek style - reasoning_content", + jsonData: `{"role":"assistant","content":"Hello","reasoning_content":"This is my reasoning"}`, + expected: "This is my reasoning", + }, + { + name: "OpenAI style - reasoning", + jsonData: `{"role":"assistant","content":"Hello","reasoning":"This is my reasoning"}`, + expected: "This is my reasoning", + }, + { + name: "Both fields present - reasoning_content takes priority", + jsonData: `{"role":"assistant","content":"Hello",` + + `"reasoning_content":"Priority","reasoning":"Fallback"}`, + expected: "Priority", + }, + { + name: "Only reasoning field", + jsonData: `{"role":"assistant","reasoning":"Only reasoning field"}`, + expected: "Only reasoning field", + }, + { + name: "No reasoning fields", + jsonData: `{"role":"assistant","content":"Hello"}`, + expected: "", + }, + { + name: "Empty reasoning_content with reasoning value", + jsonData: `{"role":"assistant","reasoning_content":"","reasoning":"Fallback value"}`, + expected: "Fallback value", + }, + { + name: "Non-empty reasoning_content, empty reasoning", + jsonData: `{"role":"assistant","content":"Hello",` + + `"reasoning_content":"Primary","reasoning":""}`, + expected: "Primary", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var msg openai.ChatCompletionMessage + err := json.Unmarshal([]byte(tt.jsonData), &msg) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if msg.ReasoningContent != tt.expected { + t.Errorf("Expected ReasoningContent to be %q, got %q", tt.expected, msg.ReasoningContent) + } + }) + } +} + +// TestChatCompletionMessage_UnmarshalError tests error handling. +func TestChatCompletionMessage_UnmarshalError(t *testing.T) { + var msg openai.ChatCompletionMessage + invalidJSON := `{"role":"assistant","content":}` + err := json.Unmarshal([]byte(invalidJSON), &msg) + if err == nil { + t.Error("Expected error when unmarshaling invalid JSON, got nil") + } +} + +// TestChatCompletionMessage_MultiContent_ReasoningFieldSupport tests reasoning field support +// with MultiContent messages. +func TestChatCompletionMessage_MultiContent_ReasoningFieldSupport(t *testing.T) { + tests := []struct { + name string + jsonData string + expected string + }{ + { + name: "MultiContent with reasoning_content", + jsonData: `{"role":"assistant","content":[{"type":"text","text":"Hello"}],"reasoning_content":"Multi reasoning"}`, + expected: "Multi reasoning", + }, + { + name: "MultiContent with reasoning", + jsonData: `{"role":"assistant","content":[{"type":"text","text":"Hello"}],"reasoning":"Multi reasoning"}`, + expected: "Multi reasoning", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var msg openai.ChatCompletionMessage + err := json.Unmarshal([]byte(tt.jsonData), &msg) + if err != nil { + t.Fatalf("Failed to unmarshal JSON: %v", err) + } + + if msg.ReasoningContent != tt.expected { + t.Errorf("Expected ReasoningContent to be %q, got %q", tt.expected, msg.ReasoningContent) + } + }) + } +} + +// TestChatCompletionStreamChoiceDelta_MarshalJSON tests that marshaling preserves +// the reasoning_content field name. +func TestChatCompletionStreamChoiceDelta_MarshalJSON(t *testing.T) { + delta := openai.ChatCompletionStreamChoiceDelta{ + Role: "assistant", + Content: "Hello", + ReasoningContent: "Test reasoning", + } + + data, err := json.Marshal(delta) + if err != nil { + t.Fatalf("Failed to marshal delta: %v", err) + } + + // Verify that it's marshaled as reasoning_content + var result map[string]interface{} + err = json.Unmarshal(data, &result) + if err != nil { + t.Fatalf("Failed to unmarshal result: %v", err) + } + + if _, hasReasoning := result["reasoning"]; hasReasoning { + t.Error("Marshaled JSON should not contain 'reasoning' field") + } + + if reasoningContent, ok := result["reasoning_content"].(string); !ok || reasoningContent != "Test reasoning" { + t.Errorf("Expected reasoning_content to be 'Test reasoning', got %v", result["reasoning_content"]) + } +} + +// TestRealWorldStreamingResponse tests parsing a real-world streaming response +// with reasoning field (similar to the new_api_output.txt format). +func TestRealWorldStreamingResponse(t *testing.T) { + // Simulate a chunk from the new_api_output.txt file + jsonData := `{ + "id":"gen-1763431956-test", + "provider":"Azure", + "model":"openai/gpt-5", + "object":"chat.completion.chunk", + "created":1763431956, + "choices":[{ + "index":0, + "delta":{ + "role":"assistant", + "content":"", + "reasoning":"quantum" + }, + "finish_reason":null, + "logprobs":null + }] + }` + + var response openai.ChatCompletionStreamResponse + err := json.Unmarshal([]byte(jsonData), &response) + if err != nil { + t.Fatalf("Failed to unmarshal streaming response: %v", err) + } + + if len(response.Choices) != 1 { + t.Fatalf("Expected 1 choice, got %d", len(response.Choices)) + } + + delta := response.Choices[0].Delta + if delta.ReasoningContent != "quantum" { + t.Errorf("Expected ReasoningContent to be 'quantum', got %q", delta.ReasoningContent) + } + + if delta.Role != "assistant" { + t.Errorf("Expected Role to be 'assistant', got %q", delta.Role) + } +} diff --git a/chat_stream.go b/chat_stream.go index 80d16cc63..4f209ec6d 100644 --- a/chat_stream.go +++ b/chat_stream.go @@ -2,6 +2,7 @@ package openai import ( "context" + "encoding/json" "net/http" ) @@ -12,13 +13,46 @@ type ChatCompletionStreamChoiceDelta struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` Refusal string `json:"refusal,omitempty"` - // This property is used for the "reasoning" feature supported by deepseek-reasoner - // which is not in the official documentation. - // the doc from deepseek: - // - https://api-docs.deepseek.com/api/create-chat-completion#responses + // This property is used for the "reasoning" feature supported by reasoning models. + // Supports both reasoning_content (DeepSeek style) and reasoning (OpenAI style) fields. + // DeepSeek doc: https://api-docs.deepseek.com/api/create-chat-completion#responses ReasoningContent string `json:"reasoning_content,omitempty"` } +// UnmarshalJSON custom unmarshaler to support both reasoning_content and reasoning fields. +func (d *ChatCompletionStreamChoiceDelta) UnmarshalJSON(data []byte) error { + type deltaAlias struct { + Content string `json:"content,omitempty"` + Role string `json:"role,omitempty"` + FunctionCall *FunctionCall `json:"function_call,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Refusal string `json:"refusal,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + } + + var aux deltaAlias + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + *d = ChatCompletionStreamChoiceDelta{ + Content: aux.Content, + Role: aux.Role, + FunctionCall: aux.FunctionCall, + ToolCalls: aux.ToolCalls, + Refusal: aux.Refusal, + ReasoningContent: aux.ReasoningContent, + } + + // Fallback to reasoning field if reasoning_content is empty + if d.ReasoningContent == "" { + d.ReasoningContent = aux.Reasoning + } + + return nil +} + type ChatCompletionStreamChoiceLogprobs struct { Content []ChatCompletionTokenLogprob `json:"content,omitempty"` Refusal []ChatCompletionTokenLogprob `json:"refusal,omitempty"`