Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
relayInfo.RetryIndex = 0
relayInfo.LastError = nil

for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
retryLimit := common.RetryTimes
for ; retryParam.GetRetry() <= retryLimit; retryParam.IncreaseRetry() {
relayInfo.RetryIndex = retryParam.GetRetry()
channel, channelErr := getChannel(c, relayInfo, retryParam)
if channelErr != nil {
Expand Down Expand Up @@ -231,7 +232,13 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {

processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)

if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
reasoningSignatureRetry := shouldRetryOpenAIReasoningSignatureInvalid(c, relayInfo, newAPIError)
if reasoningSignatureRetry {
retryLimit++
continue
}

if !shouldRetry(c, newAPIError, retryLimit-retryParam.GetRetry()) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
break
}
}
Expand All @@ -248,6 +255,25 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
}

func shouldRetryOpenAIReasoningSignatureInvalid(c *gin.Context, info *relaycommon.RelayInfo, err *types.NewAPIError) bool {
if info == nil || info.ChannelMeta == nil || err == nil {
return false
}
if info.ApiType != constant.APITypeOpenAI {
return false
}
if !info.ChannelSetting.EnableThinkingSignatureFallback {
return false
}
if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact {
return false
}
if err.GetErrorCode() != types.ErrorCodeThinkingSignatureInvalid {
return false
}
return service.MarkOpenAIReasoningSignatureInvalid(c)
}

var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
CheckOrigin: func(r *http.Request) bool {
Expand Down
84 changes: 84 additions & 0 deletions controller/relay_reasoning_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package controller

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestShouldRetryOpenAIReasoningSignatureInvalid(t *testing.T) {
originalRedisEnabled := common.RedisEnabled
common.RedisEnabled = false
t.Cleanup(func() {
common.RedisEnabled = originalRedisEnabled
})

newContext := func(encryptedContent string) *gin.Context {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
input := []byte(`[{"type":"reasoning","encrypted_content":"` + encryptedContent + `"}]`)
_, _, err := service.PrepareOpenAIResponsesReasoningInput(ctx, input, true)
require.NoError(t, err)
return ctx
}
invalidSignature := types.WithOpenAIError(types.OpenAIError{
Code: string(types.ErrorCodeThinkingSignatureInvalid),
Message: "encrypted content could not be verified",
}, http.StatusBadRequest)

openAIResponses := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
ChannelMeta: &relaycommon.ChannelMeta{
ApiType: constant.APITypeOpenAI,
ChannelSetting: dto.ChannelSettings{
EnableThinkingSignatureFallback: true,
},
},
}
ctx := newContext("controller-openai-responses")
assert.True(t, shouldRetryOpenAIReasoningSignatureInvalid(ctx, openAIResponses, invalidSignature))
assert.False(t, shouldRetryOpenAIReasoningSignatureInvalid(ctx, openAIResponses, invalidSignature), "the fallback adds only one retry")

nonOpenAI := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
ChannelMeta: &relaycommon.ChannelMeta{
ApiType: constant.APITypeCodex,
},
}
assert.False(t, shouldRetryOpenAIReasoningSignatureInvalid(newContext("controller-codex"), nonOpenAI, invalidSignature))

disabledOpenAI := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeResponses,
ChannelMeta: &relaycommon.ChannelMeta{
ApiType: constant.APITypeOpenAI,
},
}
assert.False(t, shouldRetryOpenAIReasoningSignatureInvalid(newContext("controller-disabled-openai"), disabledOpenAI, invalidSignature))

openAIChat := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeChatCompletions,
ChannelMeta: &relaycommon.ChannelMeta{
ApiType: constant.APITypeOpenAI,
ChannelSetting: dto.ChannelSettings{
EnableThinkingSignatureFallback: true,
},
},
}
assert.False(t, shouldRetryOpenAIReasoningSignatureInvalid(newContext("controller-openai-chat"), openAIChat, invalidSignature))

otherError := types.WithOpenAIError(types.OpenAIError{
Code: "invalid_request_error",
Message: "bad request",
}, http.StatusBadRequest)
assert.False(t, shouldRetryOpenAIReasoningSignatureInvalid(newContext("controller-other-error"), openAIResponses, otherError))
}
13 changes: 7 additions & 6 deletions dto/channel_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import (
)

type ChannelSettings struct {
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
EnableThinkingSignatureFallback bool `json:"enable_thinking_signature_fallback,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}

type VertexKeyType string
Expand Down
38 changes: 37 additions & 1 deletion relay/responses_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/QuantumNous/new-api/types"

"github.com/gin-gonic/gin"
"github.com/tidwall/sjson"
)

func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
Expand Down Expand Up @@ -70,6 +71,23 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
}

removedReasoningEncryptedContent := 0
if info.ApiType == appconstant.APITypeOpenAI {
preparedInput, removed, err := service.PrepareOpenAIResponsesReasoningInput(
c,
request.Input,
info.ChannelSetting.EnableThinkingSignatureFallback,
)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if removed > 0 {
request.Input = preparedInput
removedReasoningEncryptedContent = removed
logger.LogWarn(c, fmt.Sprintf("removed encrypted_content from %d OpenAI reasoning input items", removed))
}
}

err = helper.ModelMappedHelper(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
Expand All @@ -86,7 +104,25 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry())
}
requestBody = common.ReaderOnly(storage)
if removedReasoningEncryptedContent == 0 {
requestBody = common.ReaderOnly(storage)
} else {
jsonData, err := storage.Bytes()
if err != nil {
return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry())
}
jsonData, err = sjson.SetRawBytes(jsonData, "input", request.Input)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
defer closer.Close()
info.UpstreamRequestBodySize = size
requestBody = body
}
} else {
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request)
if err != nil {
Expand Down
150 changes: 150 additions & 0 deletions service/openai_reasoning_fallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package service

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/gin-gonic/gin"
"github.com/samber/hot"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

const (
ginKeyOpenAIReasoningEncryptedContentHash = "openai_reasoning_encrypted_content_hash"
ginKeyOpenAIReasoningDropEncryptedContent = "openai_reasoning_drop_encrypted_content"
ginKeyOpenAIReasoningDropApplied = "openai_reasoning_drop_applied"
ginKeyOpenAIReasoningSignatureRetryAttempted = "openai_reasoning_signature_retry_attempted"

openAIReasoningFallbackCacheNamespace = "new-api:openai_reasoning_fallback:v1"
openAIReasoningFallbackCacheCapacity = 100_000
openAIReasoningFallbackTTL = 24 * time.Hour
)

var (
openAIReasoningFallbackCacheOnce sync.Once
openAIReasoningFallbackCache *cachex.HybridCache[int]
)

func getOpenAIReasoningFallbackCache() *cachex.HybridCache[int] {
openAIReasoningFallbackCacheOnce.Do(func() {
openAIReasoningFallbackCache = cachex.NewHybridCache[int](cachex.HybridCacheConfig[int]{
Namespace: cachex.Namespace(openAIReasoningFallbackCacheNamespace),
Redis: common.RDB,
RedisEnabled: func() bool {
return common.RedisEnabled && common.RDB != nil
},
RedisCodec: cachex.IntCodec{},
Memory: func() *hot.HotCache[string, int] {
return hot.NewHotCache[string, int](hot.LRU, openAIReasoningFallbackCacheCapacity).
WithTTL(openAIReasoningFallbackTTL).
WithJanitor().
Build()
},
})
})
return openAIReasoningFallbackCache
}

// PrepareOpenAIResponsesReasoningInput applies the Responses API fallback when
// the selected channel enables it or the current request already entered the
// recovery flow. The latter keeps the forced retry effective if routing falls
// back to another OpenAI channel that has the setting disabled. A later,
// independent request still needs an enabled channel before consulting the
// learned conversation cache.
func PrepareOpenAIResponsesReasoningInput(c *gin.Context, input []byte, channelEnabled bool) ([]byte, int, error) {
dropEncryptedContent := c.GetBool(ginKeyOpenAIReasoningDropEncryptedContent)
if !channelEnabled && !dropEncryptedContent {
return input, 0, nil
}

items := gjson.ParseBytes(input)
if !items.IsArray() {
return input, 0, nil
}

firstEncryptedContent := ""
items.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() != "reasoning" {
return true
}
encryptedContent := item.Get("encrypted_content")
if encryptedContent.Exists() && encryptedContent.Type == gjson.String && encryptedContent.String() != "" {
firstEncryptedContent = encryptedContent.String()
return false
}
return true
})
if firstEncryptedContent == "" {
return input, 0, nil
}

hash := sha256.Sum256([]byte(firstEncryptedContent))
cacheKey := hex.EncodeToString(hash[:])
c.Set(ginKeyOpenAIReasoningEncryptedContentHash, cacheKey)

if !dropEncryptedContent {
_, found, err := getOpenAIReasoningFallbackCache().Get(cacheKey)
if err != nil {
logger.LogWarn(c, fmt.Sprintf("openai reasoning fallback cache get failed: %v", err))
} else if found {
dropEncryptedContent = true
c.Set(ginKeyOpenAIReasoningDropEncryptedContent, true)
if err := getOpenAIReasoningFallbackCache().SetWithTTL(cacheKey, 1, openAIReasoningFallbackTTL); err != nil {
logger.LogWarn(c, fmt.Sprintf("openai reasoning fallback cache ttl refresh failed: %v", err))
}
}
}
if !dropEncryptedContent {
return input, 0, nil
}

result := input
removed := 0
index := 0
var deleteErr error
items.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "reasoning" && item.Get("encrypted_content").Exists() {
result, deleteErr = sjson.DeleteBytes(result, fmt.Sprintf("%d.encrypted_content", index))
if deleteErr != nil {
return false
}
removed++
}
index++
return true
})
if deleteErr != nil {
return input, 0, fmt.Errorf("remove reasoning encrypted_content: %w", deleteErr)
}
if removed > 0 {
c.Set(ginKeyOpenAIReasoningDropApplied, true)
}
return result, removed, nil
}

// MarkOpenAIReasoningSignatureInvalid records the conversation fallback and
// enables one immediate retry for the current request. The cache write is best
// effort; the current retry still removes encrypted_content if Redis is down.
func MarkOpenAIReasoningSignatureInvalid(c *gin.Context) bool {
if c == nil || c.GetBool(ginKeyOpenAIReasoningDropApplied) || c.GetBool(ginKeyOpenAIReasoningSignatureRetryAttempted) {
return false
}
cacheKey := c.GetString(ginKeyOpenAIReasoningEncryptedContentHash)
if cacheKey == "" {
return false
}

c.Set(ginKeyOpenAIReasoningSignatureRetryAttempted, true)
c.Set(ginKeyOpenAIReasoningDropEncryptedContent, true)
if err := getOpenAIReasoningFallbackCache().SetWithTTL(cacheKey, 1, openAIReasoningFallbackTTL); err != nil {
logger.LogWarn(c, fmt.Sprintf("openai reasoning fallback cache set failed: %v", err))
}
return true
}
Loading