From 050667e028d7e04c9f0b94025a69c1d8d38553a7 Mon Sep 17 00:00:00 2001 From: Jachin Date: Tue, 21 Jul 2026 08:19:31 -0400 Subject: [PATCH 1/4] fix(openai): recover from invalid reasoning signatures --- controller/relay.go | 29 ++++- controller/relay_reasoning_retry_test.go | 69 ++++++++++ relay/responses_handler.go | 37 +++++- service/openai_reasoning_fallback.go | 145 ++++++++++++++++++++++ service/openai_reasoning_fallback_test.go | 90 ++++++++++++++ types/error.go | 17 +-- 6 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 controller/relay_reasoning_retry_test.go create mode 100644 service/openai_reasoning_fallback.go create mode 100644 service/openai_reasoning_fallback_test.go diff --git a/controller/relay.go b/controller/relay.go index 6e91ccb60506..1604bd6d64c7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -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 { @@ -231,7 +232,15 @@ 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 { + if retryParam.GetRetry() >= retryLimit { + retryLimit++ + } + continue + } + + if !shouldRetry(c, newAPIError, retryLimit-retryParam.GetRetry()) { break } } @@ -248,6 +257,22 @@ 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.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 { diff --git a/controller/relay_reasoning_retry_test.go b/controller/relay_reasoning_retry_test.go new file mode 100644 index 000000000000..2ac0325a2d03 --- /dev/null +++ b/controller/relay_reasoning_retry_test.go @@ -0,0 +1,69 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + 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) + 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, + }, + } + 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)) + + openAIChat := &relaycommon.RelayInfo{ + RelayMode: relayconstant.RelayModeChatCompletions, + ChannelMeta: &relaycommon.ChannelMeta{ + ApiType: constant.APITypeOpenAI, + }, + } + 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)) +} diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 5fa23d099623..656423eafee6 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -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) { @@ -70,6 +71,19 @@ 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) + 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()) @@ -86,7 +100,28 @@ 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()) + } + // BodyStorage owns the returned bytes. Keep its original request body + // intact for later retries that may select a non-OpenAI API type. + jsonData = append([]byte(nil), jsonData...) + 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 { diff --git a/service/openai_reasoning_fallback.go b/service/openai_reasoning_fallback.go new file mode 100644 index 000000000000..abb70fc1fe40 --- /dev/null +++ b/service/openai_reasoning_fallback.go @@ -0,0 +1,145 @@ +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 stateless Responses API +// fallback learned from an earlier thinking_signature_invalid response. The +// first encrypted reasoning item identifies the client-side conversation. A +// cache hit refreshes the sliding TTL and removes encrypted_content from every +// reasoning item before the request is sent upstream. +func PrepareOpenAIResponsesReasoningInput(c *gin.Context, input []byte) ([]byte, int, error) { + 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) + + dropEncryptedContent := c.GetBool(ginKeyOpenAIReasoningDropEncryptedContent) + 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 +} diff --git a/service/openai_reasoning_fallback_test.go b/service/openai_reasoning_fallback_test.go new file mode 100644 index 000000000000..223ec341dd61 --- /dev/null +++ b/service/openai_reasoning_fallback_test.go @@ -0,0 +1,90 @@ +package service + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIReasoningFallbackLearnsConversationAndRemovesAllEncryptedContent(t *testing.T) { + originalRedisEnabled := common.RedisEnabled + common.RedisEnabled = false + t.Cleanup(func() { + common.RedisEnabled = originalRedisEnabled + }) + + input := []byte(`[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, + {"type":"reasoning","summary":[],"encrypted_content":"conversation-a-first"}, + {"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"encrypted_content":"conversation-a-second"}, + {"type":"custom","encrypted_content":"not-reasoning"} + ]`) + + firstContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + unchanged, removed, err := PrepareOpenAIResponsesReasoningInput(firstContext, input) + require.NoError(t, err) + assert.Zero(t, removed) + assert.JSONEq(t, string(input), string(unchanged)) + require.NotEmpty(t, firstContext.GetString(ginKeyOpenAIReasoningEncryptedContentHash)) + + require.True(t, MarkOpenAIReasoningSignatureInvalid(firstContext)) + require.False(t, MarkOpenAIReasoningSignatureInvalid(firstContext), "only one immediate retry is allowed") + retried, removed, err := PrepareOpenAIResponsesReasoningInput(firstContext, input) + require.NoError(t, err) + assert.Equal(t, 2, removed) + assert.False(t, gjson.GetBytes(retried, "1.encrypted_content").Exists()) + assert.False(t, gjson.GetBytes(retried, "2.encrypted_content").Exists()) + assert.Equal(t, "keep", gjson.GetBytes(retried, "2.summary.0.text").String()) + assert.Equal(t, "not-reasoning", gjson.GetBytes(retried, "3.encrypted_content").String()) + + nextContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + nextRequest, removed, err := PrepareOpenAIResponsesReasoningInput(nextContext, input) + require.NoError(t, err) + assert.Equal(t, 2, removed, "a later request should hit the learned conversation cache") + assert.False(t, gjson.GetBytes(nextRequest, "1.encrypted_content").Exists()) + assert.False(t, gjson.GetBytes(nextRequest, "2.encrypted_content").Exists()) + assert.False(t, MarkOpenAIReasoningSignatureInvalid(nextContext), "a cache-hit request already applied the fallback") +} + +func TestOpenAIReasoningFallbackUsesFirstEncryptedContentAsConversationKey(t *testing.T) { + originalRedisEnabled := common.RedisEnabled + common.RedisEnabled = false + t.Cleanup(func() { + common.RedisEnabled = originalRedisEnabled + }) + + learnedInput := []byte(`[ + {"type":"reasoning","encrypted_content":"conversation-b-first"}, + {"type":"reasoning","encrypted_content":"shared-later-item"} + ]`) + learnContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + _, _, err := PrepareOpenAIResponsesReasoningInput(learnContext, learnedInput) + require.NoError(t, err) + require.True(t, MarkOpenAIReasoningSignatureInvalid(learnContext)) + + differentFirstItem := []byte(`[ + {"type":"reasoning","encrypted_content":"conversation-c-first"}, + {"type":"reasoning","encrypted_content":"shared-later-item"} + ]`) + requestContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + result, removed, err := PrepareOpenAIResponsesReasoningInput(requestContext, differentFirstItem) + require.NoError(t, err) + assert.Zero(t, removed) + assert.JSONEq(t, string(differentFirstItem), string(result)) +} + +func TestOpenAIReasoningFallbackIgnoresInputsWithoutEncryptedReasoning(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + input := []byte(`[{"type":"message","role":"user"},{"type":"reasoning","summary":[]}]`) + + result, removed, err := PrepareOpenAIResponsesReasoningInput(ctx, input) + require.NoError(t, err) + assert.Zero(t, removed) + assert.JSONEq(t, string(input), string(result)) + assert.False(t, MarkOpenAIReasoningSignatureInvalid(ctx)) +} diff --git a/types/error.go b/types/error.go index 9717401ae7b2..36ad1485cf21 100644 --- a/types/error.go +++ b/types/error.go @@ -69,14 +69,15 @@ const ( ErrorCodeBadRequestBody ErrorCode = "bad_request_body" // response error - ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed" - ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code" - ErrorCodeBadResponse ErrorCode = "bad_response" - ErrorCodeBadResponseBody ErrorCode = "bad_response_body" - ErrorCodeEmptyResponse ErrorCode = "empty_response" - ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error" - ErrorCodeModelNotFound ErrorCode = "model_not_found" - ErrorCodePromptBlocked ErrorCode = "prompt_blocked" + ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed" + ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code" + ErrorCodeBadResponse ErrorCode = "bad_response" + ErrorCodeBadResponseBody ErrorCode = "bad_response_body" + ErrorCodeEmptyResponse ErrorCode = "empty_response" + ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error" + ErrorCodeModelNotFound ErrorCode = "model_not_found" + ErrorCodePromptBlocked ErrorCode = "prompt_blocked" + ErrorCodeThinkingSignatureInvalid ErrorCode = "thinking_signature_invalid" // sql error ErrorCodeQueryDataError ErrorCode = "query_data_error" From b762d8c01d6180012fd0ca42246dcecfed7ad629 Mon Sep 17 00:00:00 2001 From: Jachin Date: Tue, 21 Jul 2026 11:45:05 -0400 Subject: [PATCH 2/4] feat(channel): gate reasoning signature fallback --- controller/relay.go | 3 ++ controller/relay_reasoning_retry_test.go | 15 ++++++++ dto/channel_settings.go | 13 +++---- relay/responses_handler.go | 2 +- .../drawers/channel-mutate-drawer.tsx | 35 +++++++++++++++++++ .../channels/lib/channel-form-errors.ts | 1 + web/src/features/channels/lib/channel-form.ts | 7 ++++ web/src/features/channels/types.ts | 1 + web/src/i18n/locales/en.json | 4 ++- web/src/i18n/locales/fr.json | 4 ++- web/src/i18n/locales/ja.json | 4 ++- web/src/i18n/locales/ru.json | 4 ++- web/src/i18n/locales/vi.json | 4 ++- web/src/i18n/locales/zh-TW.json | 4 ++- web/src/i18n/locales/zh.json | 4 ++- 15 files changed, 91 insertions(+), 14 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 1604bd6d64c7..cf95221cf0a1 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -264,6 +264,9 @@ func shouldRetryOpenAIReasoningSignatureInvalid(c *gin.Context, info *relaycommo if info.ApiType != constant.APITypeOpenAI { return false } + if !info.ChannelSetting.EnableThinkingSignatureFallback { + return false + } if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact { return false } diff --git a/controller/relay_reasoning_retry_test.go b/controller/relay_reasoning_retry_test.go index 2ac0325a2d03..9fb6da65028b 100644 --- a/controller/relay_reasoning_retry_test.go +++ b/controller/relay_reasoning_retry_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -39,6 +40,9 @@ func TestShouldRetryOpenAIReasoningSignatureInvalid(t *testing.T) { RelayMode: relayconstant.RelayModeResponses, ChannelMeta: &relaycommon.ChannelMeta{ ApiType: constant.APITypeOpenAI, + ChannelSetting: dto.ChannelSettings{ + EnableThinkingSignatureFallback: true, + }, }, } ctx := newContext("controller-openai-responses") @@ -53,10 +57,21 @@ func TestShouldRetryOpenAIReasoningSignatureInvalid(t *testing.T) { } 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)) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index c92a3f988a3a..5df752a4e6c4 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -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 diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 656423eafee6..f896453d48ce 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -72,7 +72,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } removedReasoningEncryptedContent := 0 - if info.ApiType == appconstant.APITypeOpenAI { + if info.ApiType == appconstant.APITypeOpenAI && info.ChannelSetting.EnableThinkingSignatureFallback { preparedInput, removed, err := service.PrepareOpenAIResponsesReasoningInput(c, request.Input) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index fc872579af1a..4b2722b85cb4 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -284,6 +284,7 @@ const SENSITIVE_FORM_FIELDS = [ 'thinking_to_content', 'proxy', 'pass_through_body_enabled', + 'enable_thinking_signature_fallback', 'system_prompt', 'system_prompt_override', 'allow_service_tier', @@ -337,6 +338,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { values.force_format || values.thinking_to_content || values.pass_through_body_enabled || + values.enable_thinking_signature_fallback || values.system_prompt_override || values.claude_beta_query || values.upstream_model_update_check_enabled || @@ -740,6 +742,9 @@ export function ChannelMutateDrawer({ const currentForceFormat = form.watch('force_format') const currentThinkingToContent = form.watch('thinking_to_content') const currentPassThroughBodyEnabled = form.watch('pass_through_body_enabled') + const currentThinkingSignatureFallback = form.watch( + 'enable_thinking_signature_fallback' + ) const currentDisableTaskPollingSleep = form.watch( 'disable_task_polling_sleep' ) @@ -1010,6 +1015,7 @@ export function ChannelMutateDrawer({ currentForceFormat || currentThinkingToContent || currentPassThroughBodyEnabled || + currentThinkingSignatureFallback || currentDisableTaskPollingSleep || currentProxy?.trim() || currentSystemPrompt?.trim() || @@ -4100,6 +4106,35 @@ export function ChannelMutateDrawer({ /> )} + {currentType === 1 && ( + ( + +
+ + {t( + 'Recover invalid reasoning signatures' + )} + + + {t( + 'Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid' + )} + +
+ + + +
+ )} + /> + )} + >([ 'force_format', 'thinking_to_content', 'pass_through_body_enabled', + 'enable_thinking_signature_fallback', 'proxy', 'system_prompt', 'system_prompt_override', diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index e7e36827c307..b67271c17196 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -225,6 +225,7 @@ export const channelFormSchema = z .optional() .refine(isOptionalProxyURL, ERROR_MESSAGES.INVALID_PROXY), pass_through_body_enabled: z.boolean().optional(), + enable_thinking_signature_fallback: z.boolean().optional(), system_prompt: z.string().optional(), system_prompt_override: z.boolean().optional(), // Type-specific settings (stored in settings JSON) @@ -375,6 +376,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + enable_thinking_signature_fallback: false, system_prompt: '', system_prompt_override: false, // Type-specific settings @@ -413,6 +415,7 @@ export function transformChannelToFormDefaults( thinking_to_content: false, proxy: '', pass_through_body_enabled: false, + enable_thinking_signature_fallback: false, system_prompt: '', system_prompt_override: false, } @@ -425,6 +428,8 @@ export function transformChannelToFormDefaults( thinking_to_content: parsed.thinking_to_content || false, proxy: parsed.proxy || '', pass_through_body_enabled: parsed.pass_through_body_enabled || false, + enable_thinking_signature_fallback: + parsed.enable_thinking_signature_fallback === true, system_prompt: parsed.system_prompt || '', system_prompt_override: parsed.system_prompt_override || false, } @@ -542,6 +547,8 @@ function buildSettingJSON(formData: ChannelFormValues): string { thinking_to_content: formData.thinking_to_content || false, proxy: formData.proxy?.trim() || '', pass_through_body_enabled: formData.pass_through_body_enabled || false, + enable_thinking_signature_fallback: + formData.enable_thinking_signature_fallback || false, system_prompt: formData.system_prompt || '', system_prompt_override: formData.system_prompt_override || false, } diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts index 96bd5b2cfd12..52f09e0d90bf 100644 --- a/web/src/features/channels/types.ts +++ b/web/src/features/channels/types.ts @@ -84,6 +84,7 @@ export interface ChannelSettings { thinking_to_content?: boolean proxy?: string pass_through_body_enabled?: boolean + enable_thinking_signature_fallback?: boolean system_prompt?: string system_prompt_override?: boolean } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 609e72eb08c8..6fb632f7b67b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -5214,6 +5214,8 @@ "Zero retention": "Zero retention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Recover invalid reasoning signatures": "Recover invalid reasoning signatures", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid" } } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 443b3fa1b55f..8b99042c76aa 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -5214,6 +5214,8 @@ "Zero retention": "Aucune rétention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Recover invalid reasoning signatures": "Récupérer les signatures de raisonnement invalides", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "Supprimer reasoning.encrypted_content et réessayer une fois lorsque le service en amont renvoie thinking_signature_invalid" } } diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index c15de5534b82..f558a42d845d 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -5214,6 +5214,8 @@ "Zero retention": "データ保持なし", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", - "Zoom": "ズーム" + "Zoom": "ズーム", + "Recover invalid reasoning signatures": "無効な推論署名を復旧", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "アップストリームが thinking_signature_invalid を返した場合、reasoning.encrypted_content を削除して1回再試行します" } } diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index a1c18a35657e..2936811a1bb5 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -5214,6 +5214,8 @@ "Zero retention": "Без хранения данных", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Recover invalid reasoning signatures": "Восстановление после недействительной подписи рассуждений", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "Удалять reasoning.encrypted_content и повторять запрос один раз, если вышестоящий сервис возвращает thinking_signature_invalid" } } diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 19cdddb1621d..0374f3081dee 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -5214,6 +5214,8 @@ "Zero retention": "Không lưu dữ liệu", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom" + "Zoom": "Zoom", + "Recover invalid reasoning signatures": "Khôi phục chữ ký suy luận không hợp lệ", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "Xóa reasoning.encrypted_content và thử lại một lần khi dịch vụ thượng nguồn trả về thinking_signature_invalid" } } diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index d895a2b3d3fa..0f75d1efaafe 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -5214,6 +5214,8 @@ "Zero retention": "零數據保留", "Zhipu": "智譜", "Zhipu V4": "智譜 V4", - "Zoom": "縮放" + "Zoom": "縮放", + "Recover invalid reasoning signatures": "復原無效推理簽章", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "上游回傳 thinking_signature_invalid 時,刪除 reasoning.encrypted_content 並額外重試一次" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index ee80b960bd2c..3ffae59e33cd 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -5214,6 +5214,8 @@ "Zero retention": "零数据保留", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", - "Zoom": "缩放" + "Zoom": "缩放", + "Recover invalid reasoning signatures": "恢复无效推理签名", + "Remove reasoning encrypted_content and retry once when the upstream returns thinking_signature_invalid": "上游返回 thinking_signature_invalid 时,删除 reasoning.encrypted_content 并额外重试一次" } } From 30478c52c024ae6bd79e0837b3e06bc75b5a339d Mon Sep 17 00:00:00 2001 From: Jachin Date: Tue, 21 Jul 2026 11:50:19 -0400 Subject: [PATCH 3/4] fix(openai): reserve signature recovery retry --- controller/relay.go | 4 +--- relay/responses_handler.go | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index cf95221cf0a1..e8df8dda4041 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -234,9 +234,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { reasoningSignatureRetry := shouldRetryOpenAIReasoningSignatureInvalid(c, relayInfo, newAPIError) if reasoningSignatureRetry { - if retryParam.GetRetry() >= retryLimit { - retryLimit++ - } + retryLimit++ continue } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index f896453d48ce..ee7734db04ac 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -107,9 +107,6 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) } - // BodyStorage owns the returned bytes. Keep its original request body - // intact for later retries that may select a non-OpenAI API type. - jsonData = append([]byte(nil), jsonData...) jsonData, err = sjson.SetRawBytes(jsonData, "input", request.Input) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) From cabc25623638f5e4108dca8ff2231be253e01739 Mon Sep 17 00:00:00 2001 From: Jachin Date: Tue, 21 Jul 2026 22:39:34 -0400 Subject: [PATCH 4/4] fix(openai): preserve recovery across channel fallback --- controller/relay_reasoning_retry_test.go | 2 +- relay/responses_handler.go | 8 +++- service/openai_reasoning_fallback.go | 19 ++++++---- service/openai_reasoning_fallback_test.go | 46 ++++++++++++++++++++--- 4 files changed, 59 insertions(+), 16 deletions(-) diff --git a/controller/relay_reasoning_retry_test.go b/controller/relay_reasoning_retry_test.go index 9fb6da65028b..d772c6b4703b 100644 --- a/controller/relay_reasoning_retry_test.go +++ b/controller/relay_reasoning_retry_test.go @@ -27,7 +27,7 @@ func TestShouldRetryOpenAIReasoningSignatureInvalid(t *testing.T) { newContext := func(encryptedContent string) *gin.Context { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) input := []byte(`[{"type":"reasoning","encrypted_content":"` + encryptedContent + `"}]`) - _, _, err := service.PrepareOpenAIResponsesReasoningInput(ctx, input) + _, _, err := service.PrepareOpenAIResponsesReasoningInput(ctx, input, true) require.NoError(t, err) return ctx } diff --git a/relay/responses_handler.go b/relay/responses_handler.go index ee7734db04ac..25f66d34f30b 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -72,8 +72,12 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * } removedReasoningEncryptedContent := 0 - if info.ApiType == appconstant.APITypeOpenAI && info.ChannelSetting.EnableThinkingSignatureFallback { - preparedInput, removed, err := service.PrepareOpenAIResponsesReasoningInput(c, request.Input) + 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()) } diff --git a/service/openai_reasoning_fallback.go b/service/openai_reasoning_fallback.go index abb70fc1fe40..d3e9cb31b568 100644 --- a/service/openai_reasoning_fallback.go +++ b/service/openai_reasoning_fallback.go @@ -52,12 +52,18 @@ func getOpenAIReasoningFallbackCache() *cachex.HybridCache[int] { return openAIReasoningFallbackCache } -// PrepareOpenAIResponsesReasoningInput applies the stateless Responses API -// fallback learned from an earlier thinking_signature_invalid response. The -// first encrypted reasoning item identifies the client-side conversation. A -// cache hit refreshes the sliding TTL and removes encrypted_content from every -// reasoning item before the request is sent upstream. -func PrepareOpenAIResponsesReasoningInput(c *gin.Context, input []byte) ([]byte, int, error) { +// 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 @@ -83,7 +89,6 @@ func PrepareOpenAIResponsesReasoningInput(c *gin.Context, input []byte) ([]byte, cacheKey := hex.EncodeToString(hash[:]) c.Set(ginKeyOpenAIReasoningEncryptedContentHash, cacheKey) - dropEncryptedContent := c.GetBool(ginKeyOpenAIReasoningDropEncryptedContent) if !dropEncryptedContent { _, found, err := getOpenAIReasoningFallbackCache().Get(cacheKey) if err != nil { diff --git a/service/openai_reasoning_fallback_test.go b/service/openai_reasoning_fallback_test.go index 223ec341dd61..9b2c1c823911 100644 --- a/service/openai_reasoning_fallback_test.go +++ b/service/openai_reasoning_fallback_test.go @@ -26,7 +26,7 @@ func TestOpenAIReasoningFallbackLearnsConversationAndRemovesAllEncryptedContent( ]`) firstContext, _ := gin.CreateTestContext(httptest.NewRecorder()) - unchanged, removed, err := PrepareOpenAIResponsesReasoningInput(firstContext, input) + unchanged, removed, err := PrepareOpenAIResponsesReasoningInput(firstContext, input, true) require.NoError(t, err) assert.Zero(t, removed) assert.JSONEq(t, string(input), string(unchanged)) @@ -34,7 +34,7 @@ func TestOpenAIReasoningFallbackLearnsConversationAndRemovesAllEncryptedContent( require.True(t, MarkOpenAIReasoningSignatureInvalid(firstContext)) require.False(t, MarkOpenAIReasoningSignatureInvalid(firstContext), "only one immediate retry is allowed") - retried, removed, err := PrepareOpenAIResponsesReasoningInput(firstContext, input) + retried, removed, err := PrepareOpenAIResponsesReasoningInput(firstContext, input, true) require.NoError(t, err) assert.Equal(t, 2, removed) assert.False(t, gjson.GetBytes(retried, "1.encrypted_content").Exists()) @@ -43,7 +43,7 @@ func TestOpenAIReasoningFallbackLearnsConversationAndRemovesAllEncryptedContent( assert.Equal(t, "not-reasoning", gjson.GetBytes(retried, "3.encrypted_content").String()) nextContext, _ := gin.CreateTestContext(httptest.NewRecorder()) - nextRequest, removed, err := PrepareOpenAIResponsesReasoningInput(nextContext, input) + nextRequest, removed, err := PrepareOpenAIResponsesReasoningInput(nextContext, input, true) require.NoError(t, err) assert.Equal(t, 2, removed, "a later request should hit the learned conversation cache") assert.False(t, gjson.GetBytes(nextRequest, "1.encrypted_content").Exists()) @@ -63,7 +63,7 @@ func TestOpenAIReasoningFallbackUsesFirstEncryptedContentAsConversationKey(t *te {"type":"reasoning","encrypted_content":"shared-later-item"} ]`) learnContext, _ := gin.CreateTestContext(httptest.NewRecorder()) - _, _, err := PrepareOpenAIResponsesReasoningInput(learnContext, learnedInput) + _, _, err := PrepareOpenAIResponsesReasoningInput(learnContext, learnedInput, true) require.NoError(t, err) require.True(t, MarkOpenAIReasoningSignatureInvalid(learnContext)) @@ -72,7 +72,7 @@ func TestOpenAIReasoningFallbackUsesFirstEncryptedContentAsConversationKey(t *te {"type":"reasoning","encrypted_content":"shared-later-item"} ]`) requestContext, _ := gin.CreateTestContext(httptest.NewRecorder()) - result, removed, err := PrepareOpenAIResponsesReasoningInput(requestContext, differentFirstItem) + result, removed, err := PrepareOpenAIResponsesReasoningInput(requestContext, differentFirstItem, true) require.NoError(t, err) assert.Zero(t, removed) assert.JSONEq(t, string(differentFirstItem), string(result)) @@ -82,9 +82,43 @@ func TestOpenAIReasoningFallbackIgnoresInputsWithoutEncryptedReasoning(t *testin ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) input := []byte(`[{"type":"message","role":"user"},{"type":"reasoning","summary":[]}]`) - result, removed, err := PrepareOpenAIResponsesReasoningInput(ctx, input) + result, removed, err := PrepareOpenAIResponsesReasoningInput(ctx, input, true) require.NoError(t, err) assert.Zero(t, removed) assert.JSONEq(t, string(input), string(result)) assert.False(t, MarkOpenAIReasoningSignatureInvalid(ctx)) } + +func TestOpenAIReasoningFallbackRecoveryRetrySurvivesChannelSwitch(t *testing.T) { + originalRedisEnabled := common.RedisEnabled + common.RedisEnabled = false + t.Cleanup(func() { + common.RedisEnabled = originalRedisEnabled + }) + + input := []byte(`[{"type":"reasoning","encrypted_content":"cross-channel-recovery"}]`) + requestContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + + unchanged, removed, err := PrepareOpenAIResponsesReasoningInput(requestContext, input, true) + require.NoError(t, err) + assert.Zero(t, removed) + assert.JSONEq(t, string(input), string(unchanged)) + require.True(t, MarkOpenAIReasoningSignatureInvalid(requestContext)) + + retried, removed, err := PrepareOpenAIResponsesReasoningInput(requestContext, input, false) + require.NoError(t, err) + assert.Equal(t, 1, removed, "the active recovery retry must survive fallback to a disabled channel") + assert.False(t, gjson.GetBytes(retried, "0.encrypted_content").Exists()) + + disabledChannelContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + disabledRequest, removed, err := PrepareOpenAIResponsesReasoningInput(disabledChannelContext, input, false) + require.NoError(t, err) + assert.Zero(t, removed, "a later request must still honor the selected channel setting") + assert.JSONEq(t, string(input), string(disabledRequest)) + + enabledChannelContext, _ := gin.CreateTestContext(httptest.NewRecorder()) + enabledRequest, removed, err := PrepareOpenAIResponsesReasoningInput(enabledChannelContext, input, true) + require.NoError(t, err) + assert.Equal(t, 1, removed, "an enabled channel should apply the learned conversation fallback") + assert.False(t, gjson.GetBytes(enabledRequest, "0.encrypted_content").Exists()) +}