mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-06 16:50:28 +00: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:
3 files changed
+266
No files matched your search
@@ -182,6 +182,11 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
|||||||
userAgent string,
|
userAgent string,
|
||||||
grokCacheIdentity string,
|
grokCacheIdentity string,
|
||||||
) (*http.Response, error) {
|
) (*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)
|
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||||
upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body))
|
upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||||||
releaseUpstreamCtx()
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
"go.uber.org/zap"
|
"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