From 9b99f6c1f33b3b3576bddfb929830c353ee77ccb Mon Sep 17 00:00:00 2001 From: Zbl1007 <1399853961@qq.com> Date: Thu, 4 Jun 2026 11:34:54 +0800 Subject: [PATCH] fix(apicompat): surface DeepSeek reasoning-only replies --- .../chatcompletions_responses_bridge.go | 31 ++++++ ...letions_responses_stream_lifecycle_test.go | 100 ++++++++++++++++++ .../chatcompletions_responses_test.go | 59 +++++++++++ ...ai_gateway_responses_chat_fallback_test.go | 39 +++++++ 4 files changed, 229 insertions(+) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index cc51cba2d2..f03e0bdce3 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -525,6 +525,9 @@ func chatMessageToResponsesOutput(message ChatMessage) []ResponsesOutput { } text := chatMessageContentText(message.Content) + if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 { + text = message.ReasoningContent + } if text != "" || len(message.ToolCalls) == 0 { outputs = append(outputs, ResponsesOutput{ Type: "message", @@ -787,6 +790,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr // Close a reasoning item that never transitioned to content (reasoning-only // or empty completion). events = append(events, closeChatReasoningItem(state)...) + events = append(events, synthesizeChatReasoningFallbackMessage(state)...) if state.MessageItemID != "" { if state.TextPartOpen { @@ -917,6 +921,33 @@ func closeChatReasoningItem(state *ChatCompletionsToResponsesStreamState) []Resp } } +func synthesizeChatReasoningFallbackMessage(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent { + if state == nil || + state.MessageItemID != "" || + state.Text.Len() > 0 || + state.Reasoning.Len() == 0 || + len(state.ToolCalls) > 0 { + return nil + } + + text := state.Reasoning.String() + if strings.TrimSpace(text) == "" { + return nil + } + + var events []ResponsesStreamEvent + events = append(events, ensureChatToResponsesMessageItem(state)...) + events = append(events, ensureChatToResponsesTextPart(state)...) + _, _ = state.Text.WriteString(text) + events = append(events, chatToResponsesEvent(state, "response.output_text.delta", &ResponsesStreamEvent{ + OutputIndex: state.MessageIndex, + ContentIndex: 0, + Delta: text, + ItemID: state.MessageItemID, + })) + return events +} + func ensureChatToResponsesMessageItem(state *ChatCompletionsToResponsesStreamState) []ResponsesStreamEvent { if state.MessageItemID != "" { return nil diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go index beb473038b..b71d1ba78c 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_stream_lifecycle_test.go @@ -45,6 +45,106 @@ func TestStream_ReasoningOpensItemBeforeDelta(t *testing.T) { } } +func TestStream_ReasoningOnlySynthesizesVisibleText(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}`, + `{"choices":[{"index":0,"delta":{"reasoning_content":"thinking before final"}}]}`, + `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`, + }) + + open := map[int]string{} + var sawTextDelta, sawTextDone, sawMessageDone bool + for _, e := range events { + switch e.Type { + case "response.output_item.added": + require.NotNil(t, e.Item) + open[e.OutputIndex] = e.Item.Type + case "response.output_text.delta": + sawTextDelta = true + require.Equalf(t, "message", open[e.OutputIndex], "fallback text delta before its item was opened") + require.Equal(t, "thinking before final", e.Delta) + case "response.output_text.done": + sawTextDone = true + require.Equal(t, "thinking before final", e.Text) + case "response.output_item.done": + if e.Item != nil && e.Item.Type == "message" { + sawMessageDone = true + require.Equal(t, "thinking before final", e.Item.Content[0].Text) + } + case "response.completed": + require.NotNil(t, e.Response) + require.Equal(t, "incomplete", e.Response.Status) + require.NotNil(t, e.Response.IncompleteDetails) + require.Equal(t, "max_output_tokens", e.Response.IncompleteDetails.Reason) + require.Len(t, e.Response.Output, 2) + require.Equal(t, "reasoning", e.Response.Output[0].Type) + require.Equal(t, "message", e.Response.Output[1].Type) + require.Equal(t, "thinking before final", e.Response.Output[1].Content[0].Text) + } + } + require.True(t, sawTextDelta, "reasoning-only stream must produce visible text delta") + require.True(t, sawTextDone, "reasoning-only stream must close visible text part") + require.True(t, sawMessageDone, "reasoning-only stream must close synthesized message item") +} + +func TestStream_ReasoningOnlyBlankDoesNotSynthesizeVisibleText(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"reasoning_content":" "}}]}`, + `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + }) + + for _, e := range events { + require.NotEqual(t, "response.output_text.delta", e.Type) + if e.Type == "response.completed" { + require.NotNil(t, e.Response) + require.Len(t, e.Response.Output, 2) + require.Equal(t, "reasoning", e.Response.Output[0].Type) + require.Equal(t, "message", e.Response.Output[1].Type) + require.Equal(t, "", e.Response.Output[1].Content[0].Text) + } + } +} + +func TestStream_ReasoningThenContentDoesNotDuplicateFallbackText(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"reasoning_content":"private plan"}}]}`, + `{"choices":[{"index":0,"delta":{"content":"final answer"}}]}`, + `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + }) + + var textDeltas []string + for _, e := range events { + switch e.Type { + case "response.output_text.delta": + textDeltas = append(textDeltas, e.Delta) + case "response.completed": + require.NotNil(t, e.Response) + require.Len(t, e.Response.Output, 2) + require.Equal(t, "private plan", e.Response.Output[0].Summary[0].Text) + require.Equal(t, "final answer", e.Response.Output[1].Content[0].Text) + } + } + require.Equal(t, []string{"final answer"}, textDeltas) +} + +func TestStream_ReasoningThenToolCallDoesNotSynthesizeVisibleText(t *testing.T) { + events := collectStreamEvents(t, []string{ + `{"choices":[{"index":0,"delta":{"reasoning_content":"call a tool"}}]}`, + `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{}"}}]}}]}`, + `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + }) + + for _, e := range events { + require.NotEqual(t, "response.output_text.delta", e.Type) + if e.Type == "response.completed" { + require.NotNil(t, e.Response) + require.Len(t, e.Response.Output, 2) + require.Equal(t, "reasoning", e.Response.Output[0].Type) + require.Equal(t, "function_call", e.Response.Output[1].Type) + } + } +} + // TestStream_ToolCallLifecycleComplete guards that a tool call is fully closed // (function_call_arguments.done + output_item.done with full arguments), which // codex needs to execute the call. diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index b03b012fc7..c12715e112 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -260,6 +260,65 @@ func TestChatCompletionsToResponses_EmptyContentNeverNull(t *testing.T) { } } +func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMessageText(t *testing.T) { + content := json.RawMessage(`""`) + resp := &ChatCompletionsResponse{ + ID: "chatcmpl_deepseek_reasoning_only", + Object: "chat.completion", + Model: "deepseek-reasoner", + Choices: []ChatChoice{{ + Index: 0, + Message: ChatMessage{ + Role: "assistant", + Content: content, + ReasoningContent: "reasoning-only answer", + }, + FinishReason: "stop", + }}, + } + + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner") + + require.Len(t, out.Output, 2) + require.Equal(t, "reasoning", out.Output[0].Type) + require.Equal(t, "message", out.Output[1].Type) + require.Len(t, out.Output[1].Content, 1) + assert.Equal(t, "reasoning-only answer", out.Output[1].Content[0].Text) +} + +func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFallbackToMessageText(t *testing.T) { + content := json.RawMessage(`""`) + resp := &ChatCompletionsResponse{ + ID: "chatcmpl_deepseek_reasoning_tool", + Object: "chat.completion", + Model: "deepseek-reasoner", + Choices: []ChatChoice{{ + Index: 0, + Message: ChatMessage{ + Role: "assistant", + Content: content, + ReasoningContent: "call a tool", + ToolCalls: []ChatToolCall{{ + ID: "call_a", + Type: "function", + Function: ChatFunctionCall{ + Name: "exec", + Arguments: `{}`, + }, + }}, + }, + FinishReason: "tool_calls", + }}, + } + + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner") + + require.Len(t, out.Output, 2) + require.Equal(t, "reasoning", out.Output[0].Type) + require.Equal(t, "function_call", out.Output[1].Type) + assert.Equal(t, "exec", out.Output[1].Name) +} + func TestChatCompletionsToResponses_SystemArrayContent(t *testing.T) { req := &ChatCompletionsRequest{ Model: "gpt-4o", diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index abb645e833..ee484001f6 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -102,6 +102,45 @@ func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t require.NotNil(t, result.FirstTokenMs) } +func TestForwardResponses_DeepSeekReasoningOnlyStreamProducesVisibleText(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"visible fallback"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_deepseek_reasoning_responses_stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Stream) + require.Contains(t, rec.Body.String(), "event: response.output_text.delta") + require.Contains(t, rec.Body.String(), `"delta":"visible fallback"`) + require.Contains(t, rec.Body.String(), `"status":"incomplete"`) + require.Contains(t, rec.Body.String(), "data: [DONE]") +} + func TestForwardResponses_AutoSupportedAccountStillUsesResponsesEndpoint(t *testing.T) { gin.SetMode(gin.TestMode)