From 1d47fd63000721e1e51b2d8681c79f361dc0afc1 Mon Sep 17 00:00:00 2001 From: L494264Tt Date: Mon, 18 May 2026 11:22:23 +0800 Subject: [PATCH 1/2] This preserves DeepSeek reasoning_content across chat compatibility paths. DeepSeek thinking-mode tool-call conversations may require the assistant reasoning_content from previous turns to be sent back in later requests. Without preserving it, those conversations can fail or lose reasoning context. Changes: - Preserve assistant reasoning_content when converting Chat Completions messages to Responses input by wrapping it as a thinking block. - Add regression coverage for non-streaming DeepSeek responses. - Add regression coverage for streaming DeepSeek deltas. - Add regression coverage that request-side messages[].reasoning_content is passed through with tool calls. Tests: go test -tags=unit ./internal/pkg/apicompat ./internal/service -run 'TestChatCompletionsToResponses_AssistantReasoningContentPreserved| TestChatCompletionsToResponses_AssistantThinkingTagPreserved| TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContent| TestForwardAsRawChatCompletions_ForcesStreamUsageUpstreamAndPassesUsageDownstream --- .../chatcompletions_responses_test.go | 28 +++++ .../apicompat/chatcompletions_to_responses.go | 24 ++++- ...penai_gateway_chat_completions_raw_test.go | 102 ++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index bf5c23d5..f869e2e8 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -379,6 +379,34 @@ func TestChatCompletionsToResponses_AssistantThinkingTagPreserved(t *testing.T) assert.Contains(t, parts[0].Text, "final answer") } +func TestChatCompletionsToResponses_AssistantReasoningContentPreserved(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + Messages: []ChatMessage{ + {Role: "user", Content: json.RawMessage(`"Hi"`)}, + { + Role: "assistant", + ReasoningContent: "internal plan", + Content: json.RawMessage(`"final answer"`), + }, + }, + } + + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 2) + + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[1].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "output_text", parts[0].Type) + assert.Contains(t, parts[0].Text, "internal plan") + assert.Contains(t, parts[0].Text, "final answer") +} + // --------------------------------------------------------------------------- // ResponsesToChatCompletions tests // --------------------------------------------------------------------------- diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 64ef5781..aac946dc 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -150,6 +150,13 @@ func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) { // empty/nil and there are tool_calls, only function_call items are emitted. func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) { var items []ResponsesInputItem + var content strings.Builder + + if m.ReasoningContent != "" { + content.WriteString("") + content.WriteString(m.ReasoningContent) + content.WriteString("") + } // Emit assistant message with output_text if content is non-empty. if len(m.Content) > 0 { @@ -158,15 +165,22 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) { return nil, err } if s != "" { - parts := []ResponsesContentPart{{Type: "output_text", Text: s}} - partsJSON, err := json.Marshal(parts) - if err != nil { - return nil, err + if content.Len() > 0 { + content.WriteByte('\n') } - items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON}) + content.WriteString(s) } } + if content.Len() > 0 { + parts := []ResponsesContentPart{{Type: "output_text", Text: content.String()}} + partsJSON, err := json.Marshal(parts) + if err != nil { + return nil, err + } + items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON}) + } + // Emit one function_call item per tool_call. for _, tc := range m.ToolCalls { args := tc.Function.Arguments diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go index 1be07fd7..d8063793 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go @@ -118,6 +118,108 @@ func TestForwardAsRawChatCompletions_ForcesStreamUsageUpstreamAndPassesUsageDown require.Contains(t, rec.Body.String(), "data: [DONE]") } +func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentNonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-reasoner","messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamJSON := `{"id":"chatcmpl_reasoning","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","reasoning_content":"think first","content":"final answer"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8}}` + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_deepseek_reasoning_json"}}, + Body: io.NopCloser(strings.NewReader(upstreamJSON)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.Equal(t, "think first", gjson.Get(rec.Body.String(), "choices.0.message.reasoning_content").String()) + require.Equal(t, "final answer", gjson.Get(rec.Body.String(), "choices.0.message.content").String()) +} + +func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-reasoner","messages":[{"role":"user","content":"hello"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", 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"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think first"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"final answer"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8}}`, + "", + "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_stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.Contains(t, rec.Body.String(), `"reasoning_content":"think first"`) + require.Contains(t, rec.Body.String(), `"content":"final answer"`) + require.Contains(t, rec.Body.String(), "data: [DONE]") +} + +func TestForwardAsRawChatCompletions_PreservesDeepSeekReasoningContentInRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"weather"},{"role":"assistant","reasoning_content":"need tool","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"cloudy"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_deepseek_reasoning_request"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"chatcmpl_request","object":"chat.completion","model":"deepseek-v4-pro","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + account := rawChatCompletionsTestAccount() + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "need tool", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning_content").String()) + require.Equal(t, "get_weather", gjson.GetBytes(upstream.lastBody, "messages.1.tool_calls.0.function.name").String()) +} + func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) { gin.SetMode(gin.TestMode) From fe3283a1d54443b3a5bf92979382544c59d63bb2 Mon Sep 17 00:00:00 2001 From: L494264Tt Date: Tue, 19 May 2026 17:17:39 +0800 Subject: [PATCH 2/2] fix: satisfy errcheck for reasoning content conversion --- .../apicompat/chatcompletions_to_responses.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index ffdd8247..fe2c150b 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -150,12 +150,10 @@ func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) { // empty/nil and there are tool_calls, only function_call items are emitted. func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) { var items []ResponsesInputItem - var content strings.Builder + content := "" if m.ReasoningContent != "" { - content.WriteString("") - content.WriteString(m.ReasoningContent) - content.WriteString("") + content = "" + m.ReasoningContent + "" } // Emit assistant message with output_text if content is non-empty. @@ -165,15 +163,15 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) { return nil, err } if s != "" { - if content.Len() > 0 { - content.WriteByte('\n') + if content != "" { + content += "\n" } - content.WriteString(s) + content += s } } - if content.Len() > 0 { - parts := []ResponsesContentPart{{Type: "output_text", Text: content.String()}} + if content != "" { + parts := []ResponsesContentPart{{Type: "output_text", Text: content}} partsJSON, err := json.Marshal(parts) if err != nil { return nil, err