diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 1f402202ca..7b59c28fd4 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -133,6 +133,17 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont return false } + // Self-built images requests always carry a matching image_generation tool, so a + // "tool choice not found in 'tools'" 400 means upstream revoked this account's + // image capability. Gated on the self-built marker: passthrough clients control + // their own tools/tool_choice and could otherwise poison a healthy account. + if isOpenAIImagesSelfBuiltRequest(ctx) && isOpenAIImageCapabilityLossError(statusCode, responseBody) { + if s != nil && s.rateLimitService != nil { + _ = s.rateLimitService.HandleOpenAIImageCapabilityLoss(stateCtx, account, statusCode, responseBody) + } + return false + } + if s == nil || account == nil { return false } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 9d4a8ef3e0..aa22a4edec 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -472,13 +472,29 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if decodeErr != nil { return nil, decodeErr } + // Responses OAuth 与 Chat 兼容入口保持一致:纯文本 system 可以无损提升后删除, + // JSON object 模式仍需在 input 中保留 JSON 指令供上游兼容校验。 + omitPromotedSystemMessages := !strings.EqualFold( + strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()), + "json_object", + ) codexResult := codexTransformResult{} if compatMessagesBridge { - codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true}) + codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{ + IsCodexCLI: isCodexCLI, + IsCompact: isCompactRequest, + SkipDefaultInstructions: true, + PreserveToolCallIDs: true, + OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages, + }) ensureCodexOAuthInstructionsField(decoded) markDecodedModified() } else { - codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest) + codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{ + IsCodexCLI: isCodexCLI, + IsCompact: isCompactRequest, + OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages, + }) } if codexResult.Error != nil { c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}}) diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 9374a6f7c7..ed11746cdb 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -37,6 +37,13 @@ type OpenAIImagesUpstreamError struct { Message string Param string UpstreamRequestID string + + // SynthesizedFromModelText marks an error the gateway inferred from the + // model's plain-text output instead of reading it off a structured upstream + // error frame. Such a verdict describes this one turn ("the model answered + // with words instead of an image"), not the account — see + // shouldCoolOpenAIImagesToolForError. + SynthesizedFromModelText bool } func (e *OpenAIImagesUpstreamError) Error() string { @@ -328,6 +335,26 @@ func openAIImageUploadToDataURL(upload OpenAIImagesUpload) (string, error) { return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(upload.Data), nil } +// openAIImagesSelfBuiltRequestContextKey marks a request whose upstream body was +// fully constructed by buildOpenAIImagesResponsesRequest, i.e. tool_choice and the +// matching image_generation tool are always both present and never client-controlled. +type openAIImagesSelfBuiltRequestContextKey struct{} + +func withOpenAIImagesSelfBuiltRequest(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, openAIImagesSelfBuiltRequestContextKey{}, true) +} + +func isOpenAIImagesSelfBuiltRequest(ctx context.Context) bool { + if ctx == nil { + return false + } + selfBuilt, _ := ctx.Value(openAIImagesSelfBuiltRequestContextKey{}).(bool) + return selfBuilt +} + func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string) ([]byte, error) { if parsed == nil { return nil, fmt.Errorf("parsed images request is required") @@ -711,6 +738,10 @@ func openAIImagesTextFallbackErrorForText(text string) *OpenAIImagesUpstreamErro ErrorType: "upstream_error", Code: "image_generation_unavailable", Message: "Upstream did not execute image generation", + // Inferred from the model's own words, not from an upstream error frame: + // good enough to fail this turn over to another account, not evidence that + // this account's image tool is down for the next 30 minutes. + SynthesizedFromModelText: true, } } @@ -1775,6 +1806,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( if err != nil { return nil, err } + upstreamCtx = withOpenAIImagesSelfBuiltRequest(upstreamCtx) upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false) if err != nil { return nil, err @@ -1922,6 +1954,26 @@ const ( openAIImagesOAuthUnavailableReason = "openai_images_oauth_tool_unavailable" ) +// shouldCoolOpenAIImagesToolForError decides whether an image_generation_unavailable +// verdict is durable enough to park the account's image tool for +// openAIImagesOAuthUnavailableCooldown. +// +// Only an upstream error frame that names the condition qualifies. A verdict the +// gateway synthesized from the model's plain-text reply does not: it merely says +// this prompt produced words instead of an image, which is prompt-dependent and +// happens on healthy accounts. Writing a 30-minute account-level cooldown from it +// is doubly wrong because the very same error is classified retryable +// (IsOpenAIImagesRetryableUpstreamError: status >= 500) and drives +// newOpenAIAccountFailoverError — so one such reply walks the pool and cools every +// account the retry touches. +// +// This mirrors the rule the alpha/search path already states in words: a +// tool-endpoint failure "仍允许本次请求换号,但不修改任何账号状态" +// (see shouldApplyOpenAIAlphaSearchAccountErrorSideEffects). +func shouldCoolOpenAIImagesToolForError(upstreamErr *OpenAIImagesUpstreamError) bool { + return upstreamErr != nil && !upstreamErr.SynthesizedFromModelText +} + func (s *OpenAIGatewayService) coolOpenAIImagesOAuthTool(ctx context.Context, account *Account) { if s == nil || s.accountRepo == nil || account == nil || account.Platform != PlatformOpenAI { return @@ -2017,7 +2069,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError( responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr) if upstreamErr.Code == "image_generation_unavailable" { - s.coolOpenAIImagesOAuthTool(ctx, account) + if shouldCoolOpenAIImagesToolForError(upstreamErr) { + s.coolOpenAIImagesOAuthTool(ctx, account) + } if responseWritten { return err } diff --git a/backend/internal/service/openai_images_tool_cooldown_test.go b/backend/internal/service/openai_images_tool_cooldown_test.go new file mode 100644 index 0000000000..0f38c200d7 --- /dev/null +++ b/backend/internal/service/openai_images_tool_cooldown_test.go @@ -0,0 +1,177 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// issue #6171:v0.1.181 起,/v1/images/generations 只要上游"回文字没回图",账号就被 +// 写 30 分钟 openai:image_generation 模型级冷却。该判据是**请求级**的(这个 prompt +// 这一轮模型选择了说话),却被当成**账号级**能力失效;又因为同一个错误被判为 +// 可重试(502)并驱动 failover,一次闲聊回复会沿着号池逐个把账号冷却掉。 + +// countingModelRateLimitRepo 记录 SetModelRateLimit 调用,用于断言"没写账号状态"。 +type countingModelRateLimitRepo struct { + accountRepoStub + calls int + scopes []string +} + +func (r *countingModelRateLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, _ time.Time, _ ...string) error { + r.calls++ + r.scopes = append(r.scopes, scope) + return nil +} + +func newImagesCooldownContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + return c, rec +} + +func imagesCooldownAccount() *Account { + return &Account{ID: 77, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "img-oauth"} +} + +func TestShouldCoolOpenAIImagesToolForError(t *testing.T) { + cases := []struct { + name string + err *OpenAIImagesUpstreamError + want bool + }{ + { + name: "nil_error", + err: nil, + want: false, + }, + { + // 网关从模型文字里推断出来的判据:只说明这一轮没出图。 + name: "synthesized_from_model_text", + err: &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadGateway, + Code: "image_generation_unavailable", + SynthesizedFromModelText: true, + }, + want: false, + }, + { + // 上游自己在 error 帧里点名该状态:这才是账号级证据,保持冷却。 + name: "structured_upstream_error_frame", + err: &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadGateway, + Code: "image_generation_unavailable", + }, + want: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, shouldCoolOpenAIImagesToolForError(tc.err)) + }) + } +} + +// 主复现:文字兜底判据不得写账号级冷却。 +func TestHandleOpenAIImagesOAuthResponseError_TextFallbackDoesNotCoolAccount(t *testing.T) { + c, _ := newImagesCooldownContext(t) + repo := &countingModelRateLimitRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := imagesCooldownAccount() + + upstreamErr := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.") + require.NotNil(t, upstreamErr) + require.Equal(t, "image_generation_unavailable", upstreamErr.Code) + + err := svc.handleOpenAIImagesOAuthResponseError( + context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses", + &http.Response{StatusCode: http.StatusOK, Header: http.Header{}}, + OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr, + ) + + require.Zero(t, repo.calls, "模型闲聊不构成账号级证据,不得写 30 分钟冷却") + + // 换号行为必须原样保留:本 PR 只撤销账号状态写入,不动 failover。 + var failover *UpstreamFailoverError + require.True(t, errors.As(err, &failover), "仍应触发换号,got %T", err) +} + +// 对照不变式:上游 error 帧点名该状态时仍然冷却,否则等于把功能整个废掉。 +func TestHandleOpenAIImagesOAuthResponseError_StructuredUnavailableStillCoolsAccount(t *testing.T) { + c, _ := newImagesCooldownContext(t) + repo := &countingModelRateLimitRepo{} + svc := &OpenAIGatewayService{accountRepo: repo} + account := imagesCooldownAccount() + + upstreamErr := &OpenAIImagesUpstreamError{ + StatusCode: http.StatusBadGateway, + ErrorType: "upstream_error", + Code: "image_generation_unavailable", + Message: "image generation tool is not available for this account", + } + + _ = svc.handleOpenAIImagesOAuthResponseError( + context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses", + &http.Response{StatusCode: http.StatusOK, Header: http.Header{}}, + OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr, + ) + + require.Equal(t, 1, repo.calls, "结构化上游证据仍须写冷却") + require.Equal(t, []string{openAIImageGenerationRateLimitKey}, repo.scopes) +} + +// 标记必须打在文字兜底的两个入口上,且不影响违规拦截分支的判定。 +func TestOpenAIImagesTextFallback_MarksSynthesizedVerdicts(t *testing.T) { + t.Run("plain_text_reply_is_synthesized", func(t *testing.T) { + err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.") + require.NotNil(t, err) + require.True(t, err.SynthesizedFromModelText) + require.Equal(t, "image_generation_unavailable", err.Code) + require.Equal(t, http.StatusBadGateway, err.StatusCode) + }) + + t.Run("body_entrypoint_is_synthesized", func(t *testing.T) { + body := []byte("event: response.completed\n" + + `data: {"type":"response.completed","response":{"id":"r","status":"completed",` + + `"output":[{"type":"message","content":[{"type":"output_text","text":"I drafted a prompt for you."}]}]}}` + + "\n\n") + err := openAIImagesTextFallbackError(body) + require.NotNil(t, err) + require.True(t, err.SynthesizedFromModelText) + }) + + t.Run("content_policy_branch_unchanged", func(t *testing.T) { + err := openAIImagesTextFallbackErrorForText("Blocked by our content policy.") + require.NotNil(t, err) + require.Equal(t, "content_policy_violation", err.Code) + require.Equal(t, http.StatusBadRequest, err.StatusCode) + // 该分支本来就不走冷却(Code 不匹配),标记与否都不改变行为; + // 断言它没有被顺手打标,避免语义漂移。 + require.False(t, err.SynthesizedFromModelText) + }) + + t.Run("empty_text_yields_no_error", func(t *testing.T) { + require.Nil(t, openAIImagesTextFallbackErrorForText(" ")) + }) +} + +// 级联的前提条件:该错误确实是可重试的,所以会带着"已写冷却"的副作用换号。 +// 这条用例把前提钉死,避免以后有人把 502 改成非重试后误以为本修复多余。 +func TestOpenAIImagesTextFallback_RemainsRetryableAndThusCascades(t *testing.T) { + err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.") + require.NotNil(t, err) + require.True(t, IsOpenAIImagesRetryableUpstreamError(err), + "文字兜底判据是可重试的——正因如此,写账号冷却会沿号池级联") +} diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index fb9a6fee27..fa750fa5b5 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -132,6 +132,44 @@ func TestOpenAIGatewayService_ResponsesUnknownModelDoesNotFallbackToGPT54(t *tes require.True(t, rec.Code >= http.StatusBadRequest) } +func TestOpenAIGatewayService_OAuthResponsesPromotesSystemMessageWithoutDuplication(t *testing.T) { + gin.SetMode(gin.TestMode) + + const systemPrompt = "Unique system prefix for Responses token accounting." + const existingInstructions = "Existing instructions." + body := []byte(`{"model":"gpt-5.4","stream":false,"instructions":"` + existingInstructions + `","input":[{"role":"system","content":"` + systemPrompt + `"},{"role":"user","content":"hello"}]}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 124, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, body) + + require.Error(t, err) + require.Nil(t, result) + require.NotEmpty(t, upstream.lastBody) + require.Equal(t, systemPrompt+"\n\n"+existingInstructions, gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int()) + require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String()) + require.Equal(t, 1, strings.Count(string(upstream.lastBody), systemPrompt)) +} + func TestOpenAIGatewayService_NativeResponsesBodyModificationPreservesHTMLChars(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index ff8bfac268..4d6be56b06 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -75,6 +75,8 @@ const ( const ( openAIImageRateLimitDefaultCooldown = time.Minute openAIImageRateLimitReason = "openai_image_rate_limited" + openAIImageCapabilityLossCooldown = 30 * time.Minute + openAIImageCapabilityLossReason = "openai_image_capability_lost" ) var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`) @@ -2190,6 +2192,44 @@ func (s *RateLimitService) HandleOpenAIImageRateLimit(ctx context.Context, accou return true } +func (s *RateLimitService) HandleOpenAIImageCapabilityLoss(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool { + if s == nil || account == nil || s.accountRepo == nil { + return false + } + if account.Platform != PlatformOpenAI { + return false + } + if !account.ShouldHandleErrorCode(statusCode) { + slog.Info("openai_image_capability_loss_skipped_by_error_code_policy", "account_id", account.ID, "status_code", statusCode) + return false + } + if !isOpenAIImageCapabilityLossError(statusCode, responseBody) { + return false + } + + resetAt := time.Now().Add(openAIImageCapabilityLossCooldown) + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, openAIImageGenerationRateLimitKey, resetAt, openAIImageCapabilityLossReason); err != nil { + slog.Warn("openai_image_capability_loss_set_model_rate_limit_failed", "account_id", account.ID, "scope", openAIImageGenerationRateLimitKey, "error", err) + return true + } + slog.Info("openai_image_capability_lost", "account_id", account.ID, "scope", openAIImageGenerationRateLimitKey, "reset_at", resetAt, "reset_in", time.Until(resetAt).Truncate(time.Second)) + return true +} + +// isOpenAIImageCapabilityLossError reports whether upstream rejected the +// image_generation tool choice that sub2api itself put into the request body. +// Only meaningful for self-built images requests, where tools always carries a +// matching image_generation entry — upstream saying otherwise means the account +// lost the capability. +func isOpenAIImageCapabilityLossError(statusCode int, body []byte) bool { + if statusCode != http.StatusBadRequest || len(body) == 0 { + return false + } + lower := strings.ToLower(string(body)) + return strings.Contains(lower, "image_generation") && + strings.Contains(lower, "not found in 'tools' parameter") +} + func isOpenAIImageRateLimitError(statusCode int, body []byte) bool { if statusCode != http.StatusTooManyRequests || len(body) == 0 { return false diff --git a/backend/internal/service/ratelimit_service_openai_image_test.go b/backend/internal/service/ratelimit_service_openai_image_test.go index 26714cf123..c62da4d80a 100644 --- a/backend/internal/service/ratelimit_service_openai_image_test.go +++ b/backend/internal/service/ratelimit_service_openai_image_test.go @@ -120,7 +120,11 @@ func TestOpenAIGatewayServiceForwardImages_ImageRateLimitReturnsFailoverAndCools require.Equal(t, openAIImageGenerationRateLimitKey, repo.modelRateLimitCalls[0].scope) } -func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *testing.T) { +// issue #6171:上游"回文字没回图"是**这一轮**的结果(模型选择了说话),不是账号能力 +// 失效。它同时被判为可重试(502)并驱动 failover,若还写 30 分钟账号级冷却,一次闲聊 +// 回复就会沿号池把每个被重试到的账号依次冷却掉。冷却仍保留给结构化上游证据,见 +// TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability。 +func TestOpenAIGatewayServiceForwardImages_TextFallbackDoesNotCoolImageCapability(t *testing.T) { gin.SetMode(gin.TestMode) repo := &modelNotFoundAccountRepoStub{} body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`) @@ -154,7 +158,6 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t }, } - before := time.Now() result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") require.Nil(t, result) @@ -162,6 +165,56 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t var failoverErr *UpstreamFailoverError require.ErrorAs(t, err, &failoverErr) require.False(t, failoverErr.RetryableOnSameAccount) + // 换号行为不变:该判据仍足以放弃本账号重试这一次请求…… + require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode) + // ……但不再写任何账号级状态,否则重试会把冷却一路刷到整个号池。 + require.Empty(t, repo.modelRateLimitCalls, + "模型回文字只说明这一轮没出图,不构成账号 30 分钟不可用的证据") +} + +// 对照不变式:上游 error 帧点名 image_generation_unavailable 时仍写冷却, +// 保证 #6171 的修复没有把这项能力保护整个废掉。 +func TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &modelNotFoundAccountRepoStub{} + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`) + upstreamSSE := "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"r\",\"error\":" + + "{\"type\":\"upstream_error\",\"code\":\"image_generation_unavailable\"," + + "\"message\":\"image generation tool is not available for this account\"}}}\n\n" + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{ + accountRepo: repo, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + account := &Account{ + ID: 206, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + before := time.Now() + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + + require.Nil(t, result) + require.Error(t, err) require.Len(t, repo.modelRateLimitCalls, 1) call := repo.modelRateLimitCalls[0] require.Equal(t, account.ID, call.accountID) @@ -169,3 +222,120 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t require.Equal(t, openAIImagesOAuthUnavailableReason, call.reason) require.WithinDuration(t, before.Add(openAIImagesOAuthUnavailableCooldown), call.resetAt, time.Second) } + +func TestOpenAIGatewayServiceForwardImages_CapabilityLossCoolsImageScope(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &modelNotFoundAccountRepoStub{} + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`) + errorBody := `{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}` + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{ + rateLimitService: &RateLimitService{accountRepo: repo}, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"X-Request-Id": []string{"req_img_capability_lost"}}, + Body: io.NopCloser(strings.NewReader(errorBody)), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + account := &Account{ + ID: 205, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + before := time.Now() + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + + require.Nil(t, result) + require.Error(t, err) + require.Len(t, repo.modelRateLimitCalls, 1) + call := repo.modelRateLimitCalls[0] + require.Equal(t, account.ID, call.accountID) + require.Equal(t, openAIImageGenerationRateLimitKey, call.scope) + require.Equal(t, openAIImageCapabilityLossReason, call.reason) + require.WithinDuration(t, before.Add(openAIImageCapabilityLossCooldown), call.resetAt, time.Second) +} + +func TestOpenAIGatewayServiceHandleUpstreamError_PassthroughCapabilityLossDoesNotCool(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}} + account := &Account{ID: 206, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + body := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`) + + disabled := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, http.Header{}, body, "gpt-5.5") + + require.False(t, disabled) + require.Empty(t, repo.modelRateLimitCalls) + _, wholeAccountBlocked := svc.openaiAccountRuntimeBlockUntil.Load(account.ID) + require.False(t, wholeAccountBlocked) +} + +func TestRateLimitServiceHandleOpenAIImageCapabilityLoss_IgnoresGenericBadRequest(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := &Account{ID: 207, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + body := []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`) + + handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body) + + require.False(t, handled) + require.Empty(t, repo.modelRateLimitCalls) +} + +func TestRateLimitServiceHandleOpenAIImageCapabilityLoss_RespectsPlatformAndErrorCodePolicy(t *testing.T) { + body := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`) + + t.Run("non_openai_platform", func(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := &Account{ID: 208, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + + handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body) + + require.False(t, handled) + require.Empty(t, repo.modelRateLimitCalls) + }) + + t.Run("custom_error_code_policy_excludes_400", func(t *testing.T) { + repo := &modelNotFoundAccountRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := &Account{ + ID: 209, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "custom_error_codes_enabled": true, + "custom_error_codes": []any{float64(http.StatusTooManyRequests)}, + }, + } + + require.False(t, account.ShouldHandleErrorCode(http.StatusBadRequest)) + handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body) + + require.False(t, handled) + require.Empty(t, repo.modelRateLimitCalls) + }) +} + +func TestIsOpenAIImageCapabilityLossError(t *testing.T) { + capabilityLossBody := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`) + genericBadRequestBody := []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`) + + require.True(t, isOpenAIImageCapabilityLossError(http.StatusBadRequest, capabilityLossBody)) + require.False(t, isOpenAIImageCapabilityLossError(http.StatusBadRequest, genericBadRequestBody)) + require.False(t, isOpenAIImageCapabilityLossError(http.StatusTooManyRequests, capabilityLossBody)) +}