fix(apicompat): accept chat reasoning alias

This commit is contained in:
wucm667
2026-08-05 18:25:58 +08:00
parent 00b8596176
commit 8aa425d22f
7 changed files with 160 additions and 13 deletions
@@ -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,
})...)
}
@@ -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)
}
@@ -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,
}))
}
@@ -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"
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
+16
View File
@@ -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
// ---------------------------------------------------------------------------