diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go index eb89b5472c..972604e0a8 100644 --- a/backend/internal/handler/admin/setting_handler_runtime.go +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -154,6 +154,33 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { }) } +func (h *SettingHandler) GetOpenAIImagesOAuthUnavailableCooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetOpenAIImagesOAuthUnavailableCooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, dto.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: settings.CooldownMinutes}) +} + +type UpdateOpenAIImagesOAuthUnavailableCooldownSettingsRequest struct { + CooldownMinutes int `json:"cooldown_minutes"` +} + +func (h *SettingHandler) UpdateOpenAIImagesOAuthUnavailableCooldownSettings(c *gin.Context) { + var req UpdateOpenAIImagesOAuthUnavailableCooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + settings := &service.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: req.CooldownMinutes} + if err := h.settingService.SetOpenAIImagesOAuthUnavailableCooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, dto.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: settings.CooldownMinutes}) +} + // GetPanelRateLimitSettings 获取面板 API 限流配置 // GET /api/v1/admin/settings/panel-rate-limit func (h *SettingHandler) GetPanelRateLimitSettings(c *gin.Context) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 0d0c1ed40f..a51601ced2 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -448,6 +448,10 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +type OpenAIImagesOAuthUnavailableCooldownSettings struct { + CooldownMinutes int `json:"cooldown_minutes"` +} + // PanelRateLimitSettings 面板 API 限流配置 DTO type PanelRateLimitSettings struct { Enabled bool `json:"enabled"` diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 39418b0d31..d23a21c62c 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -574,6 +574,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // 429默认回避配置 adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings) adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings) + // OpenAI OAuth image-tool unavailable cooldown configuration + adminSettings.GET("/openai-images-oauth-unavailable-cooldown", h.Admin.Setting.GetOpenAIImagesOAuthUnavailableCooldownSettings) + adminSettings.PUT("/openai-images-oauth-unavailable-cooldown", h.Admin.Setting.UpdateOpenAIImagesOAuthUnavailableCooldownSettings) // 面板 API 限流配置 adminSettings.GET("/panel-rate-limit", h.Admin.Setting.GetPanelRateLimitSettings) adminSettings.PUT("/panel-rate-limit", h.Admin.Setting.UpdatePanelRateLimitSettings) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 4679823777..ebcc077c83 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -537,6 +537,8 @@ const ( // SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling. SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings" + // SettingKeyOpenAIImagesOAuthUnavailableCooldownSettings stores the cooldown applied when the OAuth image tool is unavailable. + SettingKeyOpenAIImagesOAuthUnavailableCooldownSettings = "openai_images_oauth_unavailable_cooldown_settings" // SettingKeyOpenAIAPIKeyHealthBreakerSettings stores the opt-in OpenAI pool API-key breaker config. SettingKeyOpenAIAPIKeyHealthBreakerSettings = "openai_apikey_health_breaker_settings" diff --git a/backend/internal/service/openai_images_oauth_unavailable_cooldown_test.go b/backend/internal/service/openai_images_oauth_unavailable_cooldown_test.go new file mode 100644 index 0000000000..6a2a69fde2 --- /dev/null +++ b/backend/internal/service/openai_images_oauth_unavailable_cooldown_test.go @@ -0,0 +1,59 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestOpenAIImagesOAuthUnavailableCooldownSettingsDefaultAndStoredValue(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 30, settings.CooldownMinutes) + + require.NoError(t, svc.SetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background(), &OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: 7})) + settings, err = svc.GetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, 7, settings.CooldownMinutes) +} + +func TestSetOpenAIImagesOAuthUnavailableCooldownSettingsBoundaries(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, minutes := range []int{1, openAIImagesOAuthUnavailableMaxCooldownMinutes} { + err := svc.SetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background(), &OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: minutes}) + require.NoError(t, err, "should accept cooldown_minutes=%d", minutes) + } + + for _, minutes := range []int{0, -1, openAIImagesOAuthUnavailableMaxCooldownMinutes + 1} { + err := svc.SetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background(), &OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: minutes}) + require.ErrorContains(t, err, "cooldown_minutes must be between 1-120") + } +} + +func TestOpenAIImagesOAuthUnavailableCooldownSettingsRejectsOverflowingValue(t *testing.T) { + maxInt := int(^uint(0) >> 1) + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + err := svc.SetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background(), &OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: maxInt}) + require.ErrorContains(t, err, "cooldown_minutes must be between 1-120") + + for _, minutes := range []int{openAIImagesOAuthUnavailableMaxCooldownMinutes + 1, maxInt} { + data, marshalErr := json.Marshal(OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: minutes}) + require.NoError(t, marshalErr) + repo.data[SettingKeyOpenAIImagesOAuthUnavailableCooldownSettings] = string(data) + + settings, getErr := svc.GetOpenAIImagesOAuthUnavailableCooldownSettings(context.Background()) + require.NoError(t, getErr) + require.Equal(t, openAIImagesOAuthUnavailableDefaultCooldownMinutes, settings.CooldownMinutes) + } +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index ed11746cdb..434c352da2 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -1950,8 +1950,8 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( } const ( - openAIImagesOAuthUnavailableCooldown = 30 * time.Minute - openAIImagesOAuthUnavailableReason = "openai_images_oauth_tool_unavailable" + openAIImagesOAuthUnavailableDefaultCooldown = 30 * time.Minute + openAIImagesOAuthUnavailableReason = "openai_images_oauth_tool_unavailable" ) // shouldCoolOpenAIImagesToolForError decides whether an image_generation_unavailable @@ -1980,7 +1980,16 @@ func (s *OpenAIGatewayService) coolOpenAIImagesOAuthTool(ctx context.Context, ac } stateCtx, cancel := openAIAccountStateContext(ctx) defer cancel() - resetAt := time.Now().Add(openAIImagesOAuthUnavailableCooldown) + cooldown := openAIImagesOAuthUnavailableDefaultCooldown + if s.settingService != nil { + settings, err := s.settingService.GetOpenAIImagesOAuthUnavailableCooldownSettings(stateCtx) + if err != nil { + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images OAuth tool cooldown setting read failed error=%v", err) + } else { + cooldown = time.Duration(settings.CooldownMinutes) * time.Minute + } + } + resetAt := time.Now().Add(cooldown) if err := s.accountRepo.SetModelRateLimit(stateCtx, account.ID, openAIImageGenerationRateLimitKey, resetAt, openAIImagesOAuthUnavailableReason); err != nil { logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images OAuth tool cooldown write failed account_id=%d error=%v", account.ID, err) return diff --git a/backend/internal/service/ratelimit_service_openai_image_test.go b/backend/internal/service/ratelimit_service_openai_image_test.go index c62da4d80a..bb844b66db 100644 --- a/backend/internal/service/ratelimit_service_openai_image_test.go +++ b/backend/internal/service/ratelimit_service_openai_image_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -220,7 +221,23 @@ func TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapabi require.Equal(t, account.ID, call.accountID) require.Equal(t, openAIImageGenerationRateLimitKey, call.scope) require.Equal(t, openAIImagesOAuthUnavailableReason, call.reason) - require.WithinDuration(t, before.Add(openAIImagesOAuthUnavailableCooldown), call.resetAt, time.Second) + require.WithinDuration(t, before.Add(openAIImagesOAuthUnavailableDefaultCooldown), call.resetAt, time.Second) +} + +func TestOpenAIGatewayService_CoolOpenAIImagesOAuthToolUsesConfiguredCooldown(t *testing.T) { + accountRepo := &modelNotFoundAccountRepoStub{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyOpenAIImagesOAuthUnavailableCooldownSettings] = `{"cooldown_minutes":7}` + svc := &OpenAIGatewayService{ + accountRepo: accountRepo, + settingService: NewSettingService(settingRepo, &config.Config{}), + } + + before := time.Now() + svc.coolOpenAIImagesOAuthTool(context.Background(), &Account{ID: 206, Platform: PlatformOpenAI, Type: AccountTypeOAuth}) + + require.Len(t, accountRepo.modelRateLimitCalls, 1) + require.WithinDuration(t, before.Add(7*time.Minute), accountRepo.modelRateLimitCalls[0].resetAt, time.Second) } func TestOpenAIGatewayServiceForwardImages_CapabilityLossCoolsImageScope(t *testing.T) { diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 648fa1dc21..88b1ea4161 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -764,6 +764,40 @@ func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, se return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) } +func (s *SettingService) GetOpenAIImagesOAuthUnavailableCooldownSettings(ctx context.Context) (*OpenAIImagesOAuthUnavailableCooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIImagesOAuthUnavailableCooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOpenAIImagesOAuthUnavailableCooldownSettings(), nil + } + return nil, fmt.Errorf("get OpenAI images OAuth unavailable cooldown settings: %w", err) + } + if value == "" { + return DefaultOpenAIImagesOAuthUnavailableCooldownSettings(), nil + } + + var settings OpenAIImagesOAuthUnavailableCooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil || + settings.CooldownMinutes <= 0 || settings.CooldownMinutes > openAIImagesOAuthUnavailableMaxCooldownMinutes { + return DefaultOpenAIImagesOAuthUnavailableCooldownSettings(), nil + } + return &settings, nil +} + +func (s *SettingService) SetOpenAIImagesOAuthUnavailableCooldownSettings(ctx context.Context, settings *OpenAIImagesOAuthUnavailableCooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + if settings.CooldownMinutes <= 0 || settings.CooldownMinutes > openAIImagesOAuthUnavailableMaxCooldownMinutes { + return fmt.Errorf("cooldown_minutes must be between 1-%d", openAIImagesOAuthUnavailableMaxCooldownMinutes) + } + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal OpenAI images OAuth unavailable cooldown settings: %w", err) + } + return s.settingRepo.Set(ctx, SettingKeyOpenAIImagesOAuthUnavailableCooldownSettings, string(data)) +} + // GetStreamTimeoutSettings 获取流超时处理配置 func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 25d2b313e1..df5e39d850 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -565,6 +565,16 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +// OpenAIImagesOAuthUnavailableCooldownSettings controls how long an OAuth account's image capability is paused when unavailable. +type OpenAIImagesOAuthUnavailableCooldownSettings struct { + CooldownMinutes int `json:"cooldown_minutes"` +} + +const ( + openAIImagesOAuthUnavailableDefaultCooldownMinutes = 30 + openAIImagesOAuthUnavailableMaxCooldownMinutes = 120 +) + // OpenAIAPIKeyHealthBreakerSettings controls cross-instance failure counting for OpenAI pool API keys. type OpenAIAPIKeyHealthBreakerSettings struct { Enabled bool `json:"enabled"` @@ -598,6 +608,10 @@ func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings { } } +func DefaultOpenAIImagesOAuthUnavailableCooldownSettings() *OpenAIImagesOAuthUnavailableCooldownSettings { + return &OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: openAIImagesOAuthUnavailableDefaultCooldownMinutes} +} + // DefaultBetaPolicySettings 返回默认的 Beta 策略配置 // // context-1m-2025-08-07 的默认策略: