mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user