From 605b026cc4715e3fe1617c425fd1448219598493 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Thu, 16 Jul 2026 00:23:40 -0700 Subject: [PATCH] fix(gateway): route image-intent /v1/responses only to Responses-capable accounts (#4417) For OpenAI-compatible API-key accounts, /v1/responses requests with image-generation intent could be scheduled to accounts whose upstream does not support the Responses API (extra.openai_responses_supported=false). The flag was only consulted at forward time, where such accounts are silently downgraded to a Chat-Completions path that cannot produce images, causing upstream 4xx/5xx or canceled requests. Fix: - Add endpoint capability OpenAIEndpointCapabilityResponses. Its check in SupportsOpenAIEndpointCapability excludes only OpenAI API-key accounts probed as unsupported (mirroring the forward-time downgrade condition); OAuth/Grok/unprobed accounts keep existing behavior, and a responses- capable upstream must still pass the chat_completions gate. Reusing the existing requiredCapability plumbing makes every scheduler filter path enforce it with no scheduler signature changes. - Request the responses capability at the HTTP Responses and ResponsesWebSocket call sites only when imageIntent && platform==openai, so non-image requests keep the downgrade path and Grok's own image path is untouched. - Normalize max_tokens -> max_output_tokens on the native responses forward path (PlatformOpenAI), and strip prompt_cache_options alongside prompt_cache_retention/safety_identifier. /v1/images/generations continues to use native image capability (unchanged). Tests: capability truth table, scheduler exclusion of unsupported accounts, and forward-path transform behavior. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KXpzKKvsb5jW2GvgBQqnnZ --- .../handler/openai_gateway_handler.go | 19 ++++- backend/internal/service/account.go | 18 +++++ .../service/openai_account_scheduler_test.go | 69 +++++++++++++++++++ .../service/openai_gateway_forward.go | 14 +++- .../openai_gateway_service_hotpath_test.go | 54 +++++++++++++++ .../internal/service/openai_images_test.go | 64 +++++++++++++++++ 6 files changed, 235 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index b13fb01c55..ea23c0cf31 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -350,6 +350,14 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { var lastFailoverErr *service.UpstreamFailoverError var oauth429FailoverState service.OpenAIOAuth429FailoverState + // 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则 + // 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。 + // 仅对 OpenAI 平台生效:Grok 生图走独立的 forwardGrokResponses 路径,不应被过滤。 + requiredCapability := service.OpenAIEndpointCapabilityChatCompletions + if imageIntent && requestPlatform == service.PlatformOpenAI { + requiredCapability = service.OpenAIEndpointCapabilityResponses + } + for { // Streaming Forward intentionally detaches the upstream request so usage can // be drained after a disconnect. Re-check the client context before every @@ -367,7 +375,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { reqModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, - service.OpenAIEndpointCapabilityChatCompletions, + requiredCapability, requireCompact, false, !imageIntent, @@ -1599,6 +1607,13 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return ensureUserSlotHeld() } + // 与 HTTP Responses 路径保持一致:生图意图请求要求账号支持 Responses API(#4417)。 + // WSv2 传输本身已隐含 Responses 支持,此处为防御性对齐。 + requiredCapability := service.OpenAIEndpointCapabilityChatCompletions + if imageIntent && requestPlatform == service.PlatformOpenAI { + requiredCapability = service.OpenAIEndpointCapabilityResponses + } + for { if ctx.Err() != nil { return @@ -1612,7 +1627,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { reqModel, failedAccountIDs, requiredTransport, - service.OpenAIEndpointCapabilityChatCompletions, + requiredCapability, false, previousResponseCanMove, !imageIntent, diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 6aa2ae8a2f..e9a4bcd938 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -14,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) @@ -88,6 +89,12 @@ const ( OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions" OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings" OpenAIEndpointCapabilityAlphaSearch OpenAIEndpointCapability = "alpha_search" + // OpenAIEndpointCapabilityResponses 表示上游确实提供 /v1/responses 端点。 + // 与其他能力不同:支持状态来自 accounts.extra 的自动探测标记 + // (openai_responses_supported / openai_responses_mode),而非 + // credentials["openai_capabilities"] 配置集。仅用于生图意图的 /v1/responses + // 调度,避免把请求调度到会在 forward 阶段被降级为 Chat Completions 的账号(#4417)。 + OpenAIEndpointCapabilityResponses OpenAIEndpointCapability = "responses" ) const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities" @@ -1398,6 +1405,17 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa } switch capability { case OpenAIEndpointCapabilityChatCompletions: + case OpenAIEndpointCapabilityResponses: + // Responses 支持状态由 accounts.extra 的自动探测标记决定,而非 + // credentials 能力集。已探测确认不支持 /v1/responses 的 APIKey 上游 + // 必须排除——否则会在 forward 阶段被静默降级为 Chat Completions, + // 无法完成生图(#4417)。未探测/OAuth 账号保留旧行为(不排除)。 + if a.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(a.Extra) { + return false + } + // 支持 Responses 的上游同样需具备 chat 能力:复用下方 chat_completions + // 配置集校验。 + capability = OpenAIEndpointCapabilityChatCompletions case OpenAIEndpointCapabilityAlphaSearch: // Codex alpha/search 是 ChatGPT/Codex 后端工具端点,必须使用 // OAuth/PAT/AgentIdentity 这类 ChatGPT 账号凭据;API key 被发往 diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 931b8f9108..f952617021 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -599,6 +599,75 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_Embeddi require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) } +// 生图意图的 /v1/responses 请求要求 OpenAIEndpointCapabilityResponses:探测确认 +// 不支持 Responses API 的 APIKey 账号必须被排除,避免 forward 阶段降级为无法生图 +// 的 Chat Completions 直转(#4417)。 +func TestOpenAIGatewayService_SelectAccountWithScheduler_ResponsesCapabilityExcludesUnsupportedAPIKey(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(10120) + + newSvc := func(accounts []Account) *OpenAIGatewayService { + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + return &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + } + + supported := Account{ + ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, + } + // 更高优先级但探测确认不支持 Responses——若门控失效会被优先选中。 + unsupported := Account{ + ID: 37002, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5, + Extra: map[string]any{"openai_responses_supported": false}, + } + + t.Run("生图意图仅选中支持 responses 的账号", func(t *testing.T) { + svc := newSvc([]Account{supported, unsupported}) + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, "", "", "gpt-image-2", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, + false, false, false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37001), selection.Account.ID) + }) + + t.Run("仅有不支持 responses 的账号时生图意图无可用账号", func(t *testing.T) { + svc := newSvc([]Account{unsupported}) + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, "", "", "gpt-image-2", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, + false, false, false, + ) + require.Error(t, err) + require.Nil(t, selection) + }) + + t.Run("非生图路径仍可选中不支持 responses 的账号", func(t *testing.T) { + svc := newSvc([]Account{unsupported}) + selection, _, err := svc.SelectAccountWithSchedulerForCapability( + ctx, &groupID, "", "", "gpt-5.1", nil, + OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, + false, false, true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37002), selection.Account.ID) + }) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_AllowsGrokChatAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index bd8e31ad64..16c034f19d 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -409,10 +409,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markPatchDelete("max_output_tokens") } } + // /v1/responses 的规范输出上限字段是 max_output_tokens;部分客户端仍按 + // Chat Completions 习惯发送 max_tokens,兼容 Responses 上游会拒绝该字段(#4417)。 + // 仅对 OpenAI 平台归一化:Anthropic 合法使用 max_tokens,其 max_output_tokens + // 反向转换已在上方 switch 中处理。 + if account.Platform == PlatformOpenAI { + if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() { + if !gjson.GetBytes(body, "max_output_tokens").Exists() { + markPatchSet("max_output_tokens", maxTokens.Value()) + } + markPatchDelete("max_tokens") + } + } if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) { markPatchDelete("max_completion_tokens") } - for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} { + for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier", "prompt_cache_options"} { if gjson.GetBytes(body, unsupportedField).Exists() { markPatchDelete(unsupportedField) } diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index f12f47796e..c8d72f0b81 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -184,6 +184,60 @@ func TestOpenAIGatewayService_Forward_DecodedMutationKeepsLaterFieldDeletes(t *t require.Equal(t, "png", gjson.GetBytes(upstream.lastBody, "tools.0.output_format").String()) } +// #4417:/v1/responses 原生转发路径需将 Chat-Completions 风格的 max_tokens 归一化为 +// max_output_tokens,并移除兼容上游不接受的 prompt_cache_options。 +func TestOpenAIGatewayService_Forward_NormalizesMaxTokensAndStripsPromptCacheOptions(t *testing.T) { + gin.SetMode(gin.TestMode) + + runForward := func(t *testing.T, body []byte) []byte { + t.Helper() + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 4, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + return upstream.lastBody + } + + t.Run("max_tokens 归一化为 max_output_tokens 并移除 prompt_cache_options", func(t *testing.T) { + out := runForward(t, []byte(`{"model":"gpt-5.4","stream":false,"max_tokens":256,"prompt_cache_options":{"enabled":true},"input":[{"type":"message","content":"hi"}]}`)) + require.Equal(t, int64(256), gjson.GetBytes(out, "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(out, "max_tokens").Exists()) + require.False(t, gjson.GetBytes(out, "prompt_cache_options").Exists()) + }) + + t.Run("同时存在时保留 max_output_tokens 丢弃 max_tokens", func(t *testing.T) { + out := runForward(t, []byte(`{"model":"gpt-5.4","stream":false,"max_tokens":256,"max_output_tokens":512,"input":[{"type":"message","content":"hi"}]}`)) + require.Equal(t, int64(512), gjson.GetBytes(out, "max_output_tokens").Int()) + require.False(t, gjson.GetBytes(out, "max_tokens").Exists()) + }) +} + func TestOpenAIGatewayService_Forward_MappedImageModelUsesImageGate(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 2d43dc7c3d..d3579e44de 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -573,6 +573,70 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) { require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapability("unknown"))) }) + + t.Run("responses 能力:未探测的 APIKey 默认放行", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + } + + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + }) + + t.Run("responses 能力:探测确认不支持的 APIKey 被排除", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{"openai_responses_supported": false}, + } + + require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + // 非生图路径仍可选中(只要求 chat_completions)。 + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) + }) + + t.Run("responses 能力:探测确认支持的 APIKey 放行", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{"openai_responses_supported": true}, + } + + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + }) + + t.Run("responses 能力:force_chat_completions 覆盖排除 APIKey", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{"openai_responses_mode": "force_chat_completions"}, + } + + require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + }) + + t.Run("responses 能力:OAuth 账号不受探测标记影响", func(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"openai_responses_supported": false}, + } + + require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + }) + + t.Run("responses 能力:仍需通过 chat_completions 配置集校验", func(t *testing.T) { + // 未探测(默认支持 responses),但显式能力集未声明 chat_completions。 + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "openai_capabilities": []any{"embeddings"}, + }, + } + + require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityResponses)) + }) } func TestBuildOpenAIImagesURL_HandlesVersionedBaseURL(t *testing.T) {