diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index e4fa5541c..1a62f5620 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -423,19 +423,20 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str // + the reasoning→thinking mapping in ResponsesToAnthropic. func chatMessageToAnthropicBlocks(message ChatMessage) []AnthropicContentBlock { var blocks []AnthropicContentBlock + reasoning := message.reasoningText() - if message.ReasoningContent != "" { + if reasoning != "" { blocks = append(blocks, AnthropicContentBlock{ Type: "thinking", - Thinking: message.ReasoningContent, + Thinking: reasoning, }) } text := chatMessageContentText(message.Content) // DeepSeek reasoning-only fallback: when there is no text and no tool calls, // surface the reasoning content as visible text so the turn isn't empty. - if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 { - text = message.ReasoningContent + if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 { + text = reasoning } if text != "" || len(message.ToolCalls) == 0 { blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: text}) @@ -608,11 +609,12 @@ func ChatCompletionsChunkToAnthropicEvents( for _, choice := range chunk.Choices { // Reasoning content → thinking block. - if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + reasoning := choice.Delta.reasoningText() + if reasoning != nil && *reasoning != "" { events = append(events, ensureCCAnthropicThinkingBlock(state)...) events = append(events, ccAnthropicDelta(state, &AnthropicDelta{ Type: "thinking_delta", - Thinking: *choice.Delta.ReasoningContent, + Thinking: *reasoning, })...) } diff --git a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go new file mode 100644 index 000000000..87c4f9e0b --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go @@ -0,0 +1,76 @@ +package apicompat + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func readIssue5302Fixture(t *testing.T, name string, dst any) { + t.Helper() + payload, err := os.ReadFile(filepath.Join("testdata", "issue5302", name)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(payload, dst)) +} + +func TestChatReasoningAlias_AnthropicNonStreaming(t *testing.T) { + var response ChatCompletionsResponse + readIssue5302Fixture(t, "nonstream_reasoning.json", &response) + + out := ChatCompletionsResponseToAnthropic(&response, "claude-sonnet-4-20250514") + require.Len(t, out.Content, 2) + require.Equal(t, "thinking", out.Content[0].Type) + require.Equal(t, "fallback reasoning", out.Content[0].Thinking) + require.Equal(t, "final answer", out.Content[1].Text) +} + +func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) { + var chunk ChatCompletionsChunk + readIssue5302Fixture(t, "stream_reasoning.json", &chunk) + + events := ChatCompletionsChunkToAnthropicEvents(&chunk, NewChatCompletionsToAnthropicStreamState("reasoning-model")) + var thinking string + for _, event := range events { + if event.Delta != nil && event.Delta.Type == "thinking_delta" { + thinking += event.Delta.Thinking + } + } + require.Equal(t, "streamed fallback", thinking) +} + +func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) { + var response ChatCompletionsResponse + readIssue5302Fixture(t, "nonstream_reasoning.json", &response) + nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil) + require.Len(t, nonStream.Output, 2) + require.Equal(t, "reasoning", nonStream.Output[0].Type) + require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text) + + var chunk ChatCompletionsChunk + readIssue5302Fixture(t, "stream_reasoning.json", &chunk) + events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model")) + var deltas []string + for _, event := range events { + if event.Type == "response.reasoning_summary_text.delta" { + deltas = append(deltas, event.Delta) + } + } + require.Equal(t, []string{"streamed fallback"}, deltas) +} + +func TestChatReasoningAlias_ReasoningContentTakesPrecedence(t *testing.T) { + var chunk ChatCompletionsChunk + readIssue5302Fixture(t, "reasoning_content_precedence.json", &chunk) + + events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model")) + var deltas []string + for _, event := range events { + if event.Type == "response.reasoning_summary_text.delta" { + deltas = append(deltas, event.Delta) + } + } + require.Equal(t, []string{"preferred reasoning"}, deltas) +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index b5c5cf0aa..4e20fe421 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -1053,20 +1053,21 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput - if message.ReasoningContent != "" { + reasoning := message.reasoningText() + if reasoning != "" { outputs = append(outputs, ResponsesOutput{ Type: "reasoning", ID: generateItemID(), Summary: []ResponsesSummary{{ Type: "summary_text", - Text: message.ReasoningContent, + Text: reasoning, }}, }) } text := chatMessageContentText(message.Content) - if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 { - text = message.ReasoningContent + if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 { + text = reasoning } if text != "" || len(message.ToolCalls) == 0 { outputs = append(outputs, ResponsesOutput{ @@ -1328,13 +1329,14 @@ func ChatCompletionsChunkToResponsesEvents( // (output_item.added + reasoning_summary_part.added) before the first // delta, otherwise a strict client discards the delta. The leading // empty-string reasoning delta upstreams send is filtered out. - if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + reasoning := choice.Delta.reasoningText() + if reasoning != nil && *reasoning != "" { events = append(events, ensureChatReasoningItem(state)...) - _, _ = state.Reasoning.WriteString(*choice.Delta.ReasoningContent) + _, _ = state.Reasoning.WriteString(*reasoning) events = append(events, chatToResponsesEvent(state, "response.reasoning_summary_text.delta", &ResponsesStreamEvent{ OutputIndex: state.ReasoningIndex, SummaryIndex: 0, - Delta: *choice.Delta.ReasoningContent, + Delta: *reasoning, ItemID: state.ReasoningItemID, })) } diff --git a/backend/internal/pkg/apicompat/testdata/issue5302/nonstream_reasoning.json b/backend/internal/pkg/apicompat/testdata/issue5302/nonstream_reasoning.json new file mode 100644 index 000000000..e31e463b2 --- /dev/null +++ b/backend/internal/pkg/apicompat/testdata/issue5302/nonstream_reasoning.json @@ -0,0 +1,19 @@ +{ + "id": "chatcmpl-sanitized", + "object": "chat.completion", + "model": "reasoning-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "final answer", + "reasoning": "fallback reasoning", + "reasoning_details": [ + {"type": "reasoning.text", "text": "fallback reasoning"} + ] + }, + "finish_reason": "stop" + } + ] +} diff --git a/backend/internal/pkg/apicompat/testdata/issue5302/reasoning_content_precedence.json b/backend/internal/pkg/apicompat/testdata/issue5302/reasoning_content_precedence.json new file mode 100644 index 000000000..5040d46f2 --- /dev/null +++ b/backend/internal/pkg/apicompat/testdata/issue5302/reasoning_content_precedence.json @@ -0,0 +1,15 @@ +{ + "id": "chatcmpl-sanitized", + "object": "chat.completion.chunk", + "model": "reasoning-model", + "choices": [ + { + "index": 0, + "delta": { + "reasoning_content": "preferred reasoning", + "reasoning": "fallback reasoning" + }, + "finish_reason": null + } + ] +} diff --git a/backend/internal/pkg/apicompat/testdata/issue5302/stream_reasoning.json b/backend/internal/pkg/apicompat/testdata/issue5302/stream_reasoning.json new file mode 100644 index 000000000..66c4e8ae7 --- /dev/null +++ b/backend/internal/pkg/apicompat/testdata/issue5302/stream_reasoning.json @@ -0,0 +1,17 @@ +{ + "id": "chatcmpl-sanitized", + "object": "chat.completion.chunk", + "model": "reasoning-model", + "choices": [ + { + "index": 0, + "delta": { + "reasoning": "streamed fallback", + "reasoning_details": [ + {"type": "reasoning.text", "text": "streamed fallback"} + ] + }, + "finish_reason": null + } + ] +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index c4cde405e..c484a7e58 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -651,6 +651,7 @@ type ChatMessage struct { Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function" Content json.RawMessage `json:"content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` Name string `json:"name,omitempty"` ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` @@ -771,9 +772,24 @@ type ChatDelta struct { Role string `json:"role,omitempty"` Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters ReasoningContent *string `json:"reasoning_content,omitempty"` + Reasoning *string `json:"reasoning,omitempty"` ToolCalls []ChatToolCall `json:"tool_calls,omitempty"` } +func (m ChatMessage) reasoningText() string { + if m.ReasoningContent != "" { + return m.ReasoningContent + } + return m.Reasoning +} + +func (d ChatDelta) reasoningText() *string { + if d.ReasoningContent != nil { + return d.ReasoningContent + } + return d.Reasoning +} + // --------------------------------------------------------------------------- // Shared constants // ---------------------------------------------------------------------------