Merge pull request #4424 from StarryKira/fix/4417-responses-image-account-capability

fix(gateway): route image-intent /v1/responses only to Responses-capable accounts (#4417)
This commit is contained in:
Wesley Liddick
2026-07-16 15:35:45 +08:00
committed by GitHub
6 changed files with 235 additions and 3 deletions
@@ -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,
+18
View File
@@ -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 被发往
@@ -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()
@@ -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)
}
@@ -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{
@@ -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) {