diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 5f811ff25b..6730529743 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -358,17 +358,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if channelMapping.Mapped { forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel) } - // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false) - // 说明会话链不属于本次账号,携带它会触发上游会话链鉴权失败,故主动剥离改用完整 input 重建。 - // 带 function_call_output 的工具续链无法重建,保持原样(与 WS 重连恢复一致)。 - if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && - !service.ValidateFunctionCallOutputContextBytes(forwardBody).HasFunctionCallOutput { - forwardBody = service.RemovePreviousResponseIDFromBody(forwardBody) - reqLog.Debug("openai.previous_response_id_stripped_cross_group", - zap.Int64("account_id", account.ID), - zap.String("schedule_layer", scheduleDecision.Layer), - ) - } writerSizeBeforeForward := c.Writer.Size() result, err := func() (*service.OpenAIForwardResult, error) { defer func() { @@ -1504,6 +1493,18 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if channelMappingWS.Mapped { wsFirstMessage = h.gatewayService.ReplaceModelInBody(firstMessage, channelMappingWS.MappedModel) } + // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), + // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 + // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 + // 工具续链无法重建,保持原样。仅作用于首轮首包,后续 turn 的续链由 WS 转发层既有逻辑处理。 + if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && + !service.ValidateFunctionCallOutputContextBytes(wsFirstMessage).HasFunctionCallOutput { + wsFirstMessage = service.RemovePreviousResponseIDFromBody(wsFirstMessage) + reqLog.Debug("openai.websocket_previous_response_id_stripped_cross_group", + zap.Int64("account_id", account.ID), + zap.String("schedule_layer", scheduleDecision.Layer), + ) + } // WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。 requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage) diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index e737a21125..381b8c6c25 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // --------------------------------------------------------------------------- @@ -1921,6 +1922,33 @@ func TestReplaceModelInBody_InvalidJSON(t *testing.T) { require.Equal(t, arrayBody, result2) } +func TestRemovePreviousResponseIDFromBody(t *testing.T) { + t.Run("empty body returned as-is", func(t *testing.T) { + require.Equal(t, []byte{}, RemovePreviousResponseIDFromBody([]byte{})) + require.Nil(t, RemovePreviousResponseIDFromBody(nil)) + }) + + t.Run("no previous_response_id field is a no-op", func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":"hi"}`) + result := RemovePreviousResponseIDFromBody(body) + require.Equal(t, body, result) + }) + + t.Run("strips previous_response_id and preserves other fields", func(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":"resp_abc","input":"hi"}`) + result := RemovePreviousResponseIDFromBody(body) + require.False(t, gjson.GetBytes(result, "previous_response_id").Exists()) + require.Equal(t, "gpt-5", gjson.GetBytes(result, "model").String()) + require.Equal(t, "hi", gjson.GetBytes(result, "input").String()) + }) + + t.Run("empty-string previous_response_id is also stripped", func(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":""}`) + result := RemovePreviousResponseIDFromBody(body) + require.False(t, gjson.GetBytes(result, "previous_response_id").Exists()) + }) +} + // =========================================================================== // 7. isPlatformPricingMatch // ===========================================================================