fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback

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 <cursoragent@cursor.com>
This commit is contained in:
Long Li
2026-09-18 15:42:22 +09:00
co-authored by Cursor
parent efe9aab1e4
commit bcc73f8d40
3 changed files with 266 additions and 0 deletions
@@ -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()
@@ -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())
}
@@ -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
}