From bcc73f8d405892e4748bbcb0fb73af070f3745d4 Mon Sep 17 00:00:00 2001 From: Long Li Date: Fri, 18 Sep 2026 15:42:22 +0900 Subject: [PATCH] fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex histories often replay encrypted-only reasoning items. After a Responses→Chat Completions fallback, a cache miss left assistant messages without reasoning_content and DeepSeek rejected the turn with 400. Fill empty assistant fields with a single-space placeholder for DeepSeek hosts, including openai-platform accounts whose base_url points at api.deepseek.com. Existing plaintext and cache hits are left unchanged. Co-authored-by: Cursor --- .../service/openai_gateway_cc_pipeline.go | 5 + ...ai_gateway_deepseek_chat_reasoning_test.go | 193 ++++++++++++++++++ .../openai_gateway_responses_chat_fallback.go | 68 ++++++ 3 files changed, 266 insertions(+) create mode 100644 backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index e405bbee03..103423e8aa 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -182,6 +182,11 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( userAgent string, grokCacheIdentity string, ) (*http.Response, error) { + // DeepSeek thinking mode 要求历史 assistant 回传 reasoning_content。 + // Responses→CC 回退在加密-only / 缺 reasoning item 且缓存未命中时会漏掉该 + // 字段,上游 400 "The `reasoning_content` in the thinking mode must be + // passed back to the API"。在共用出站点补空格占位,真实明文不覆盖。 + body = ensureDeepSeekChatReasoningPlaceholders(account, body) upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body)) releaseUpstreamCtx() diff --git a/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go b/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go new file mode 100644 index 0000000000..23b1b04578 --- /dev/null +++ b/backend/internal/service/openai_gateway_deepseek_chat_reasoning_test.go @@ -0,0 +1,193 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func openaiPlatformDeepSeekAccount() *Account { + return &Account{ + ID: 18, + Name: "openai-deepseek-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + }, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": DefaultDeepseekBaseURL, + }, + Status: StatusActive, + Schedulable: true, + } +} + +func deepSeekChatFallbackTestConfig() *config.Config { + return &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{ + Enabled: false, + AllowInsecureHTTP: true, + }, + }, + } +} + +func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) { + deepSeekAccount := openaiPlatformDeepSeekAccount() + nativeDeepSeek := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + otherOpenAI := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"}, + } + + missing := []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`) + withPlain := []byte(`{"model":"deepseek-chat","messages":[{"role":"assistant","reasoning_content":"real thinking","content":"ok"}]}`) + + t.Run("openai_platform_deepseek_fills_empty_assistant", func(t *testing.T) { + got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, missing) + require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String()) + require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists()) + require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists()) + }) + + t.Run("native_deepseek_platform_fills_empty_assistant", func(t *testing.T) { + got := ensureDeepSeekChatReasoningPlaceholders(nativeDeepSeek, missing) + require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String()) + }) + + t.Run("does_not_overwrite_plaintext", func(t *testing.T) { + got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, withPlain) + require.Equal(t, "real thinking", gjson.GetBytes(got, "messages.0.reasoning_content").String()) + require.Equal(t, string(withPlain), string(got), "已有明文时必须原样返回") + }) + + t.Run("non_deepseek_upstream_unchanged", func(t *testing.T) { + got := ensureDeepSeekChatReasoningPlaceholders(otherOpenAI, missing) + require.Equal(t, string(missing), string(got)) + require.False(t, gjson.GetBytes(got, "messages.1.reasoning_content").Exists()) + }) + + t.Run("nil_account_unchanged", func(t *testing.T) { + got := ensureDeepSeekChatReasoningPlaceholders(nil, missing) + require.Equal(t, string(missing), string(got)) + }) +} + +type reasoningHitCache struct { + stubGatewayCache + values map[string]string +} + +func (c *reasoningHitCache) GetReasoningContent(_ context.Context, itemID string) (string, error) { + if v, ok := c.values[itemID]; ok { + return v, nil + } + return "", ErrReasoningContentNotFound +} + +func deepSeekChatHistoryWithEncryptedReasoning() []byte { + return []byte(`{ + "model":"gpt-5.6-sol", + "stream":false, + "input":[ + {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"}, + {"type":"function_call","call_id":"call_1","name":"exec","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]} + ] + }`) +} + +func newDeepSeekChatFallbackContext(t *testing.T, body []byte) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + 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") + return c +} + +func newOKChatCompletionsUpstream(requestID, body string) *httpUpstreamRecorder { + return &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{requestID}}, + Body: io.NopCloser(strings.NewReader(body)), + }} +} + +const deepSeekChatFallbackOKBody = `{"id":"chatcmpl_ph","object":"chat.completion","model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` + +func TestForwardResponses_DeepSeekChatFallbackInjectsReasoningPlaceholderOnCacheMiss(t *testing.T) { + body := deepSeekChatHistoryWithEncryptedReasoning() + c := newDeepSeekChatFallbackContext(t, body) + upstream := newOKChatCompletionsUpstream("rid_ds_rc_placeholder", deepSeekChatFallbackOKBody) + svc := &OpenAIGatewayService{ + cfg: deepSeekChatFallbackTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, upstream.lastReq.URL.String(), "/chat/completions") + require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String()) +} + +func TestForwardResponses_DeepSeekChatFallbackKeepsCachedReasoningContent(t *testing.T) { + body := deepSeekChatHistoryWithEncryptedReasoning() + c := newDeepSeekChatFallbackContext(t, body) + upstream := newOKChatCompletionsUpstream("rid_ds_rc_cached", deepSeekChatFallbackOKBody) + svc := &OpenAIGatewayService{ + cfg: deepSeekChatFallbackTestConfig(), + httpUpstream: upstream, + cache: &reasoningHitCache{values: map[string]string{"item_enc1": "cached thinking"}}, + } + + result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String()) +} + +func TestForwardResponses_NonDeepSeekChatFallbackDoesNotInjectReasoningPlaceholder(t *testing.T) { + body := deepSeekChatHistoryWithEncryptedReasoning() + c := newDeepSeekChatFallbackContext(t, body) + upstream := newOKChatCompletionsUpstream("rid_other_rc", deepSeekChatFallbackOKBody) + account := &Account{ + ID: 99, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + }, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "http://upstream.example", + }, + } + svc := &OpenAIGatewayService{ + cfg: deepSeekChatFallbackTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").Exists()) +} diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 67d3e2b4e7..381f17f630 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "net/http" + "net/url" + "strconv" "strings" "time" @@ -14,6 +16,8 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" "go.uber.org/zap" ) @@ -399,3 +403,67 @@ func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) { ) } } + +// deepSeekChatReasoningPlaceholderText 是 Chat Completions 侧 thinking-mode +// 占位明文。必须是单个空格:DeepSeek 拒绝空串,非空即可通过;LiteLLM 同样注入 +// 单个空格。 +const deepSeekChatReasoningPlaceholderText = " " + +// targetsDeepSeekAPIHost 报告该账号实际上游是否是 DeepSeek 官方 API。 +// platform=deepseek 直接命中;platform=openai 但 base_url 指向 +// api.deepseek.com 的映射账号同样命中(Codex 把 GPT 模型名映射到 DeepSeek +// 时的典型接入方式)。 +func targetsDeepSeekAPIHost(account *Account) bool { + if account == nil { + return false + } + if account.Platform == PlatformDeepseek { + return true + } + u, err := url.Parse(strings.TrimSpace(account.GetOpenAIBaseURL())) + if err != nil { + return false + } + ds, err := url.Parse(DefaultDeepseekBaseURL) + if err != nil { + return false + } + return strings.EqualFold(u.Hostname(), ds.Hostname()) +} + +// ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant +// 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的 +// assistant 消息都回传该字段,否则 400 +// "The `reasoning_content` in the thinking mode must be passed back to the API"。 +// +// 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。 +// 非 DeepSeek 上游原样返回(字节不变)。 +func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte { + if !targetsDeepSeekAPIHost(account) { + return body + } + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + updated := body + changed := false + for i, msg := range messages.Array() { + if strings.TrimSpace(msg.Get("role").String()) != "assistant" { + continue + } + if msg.Get("reasoning_content").String() != "" { + continue + } + next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning_content", deepSeekChatReasoningPlaceholderText) + if err != nil { + return body + } + updated = next + changed = true + } + if !changed { + return body + } + return updated +}