From f8b31075cd0226b16b660d6521c19ab575eba247 Mon Sep 17 00:00:00 2001 From: Ryan Chou Date: Fri, 28 Aug 2026 21:35:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20OpenAI=20403=20=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=86=B7=E5=8D=B4=E5=8F=82=E6=95=B0=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI 账号收到 403 后的处置策略此前完全硬编码在 ratelimit_service.go: 单次临时冷却 10 分钟、计数窗口 180 分钟、窗口内累计 3 次转永久错误。 403 在很多部署里是 IP / 代理出口被拒而非账号问题,管理员却只能改代码 重新构建才能调整,issue #5280 及其下多位用户对此反馈强烈。 本次把这三个参数(外加一个总开关)做成系统设置,接线完全照既有的 529 过载冷却 / 429 默认回避两条范本: - 后端:新增 OpenAI403CooldownSettings(enabled / cooldown_minutes / disable_threshold / window_minutes),设置键 openai_403_cooldown_settings, 读端 clamp、写端启用时校验范围(1-1440 分钟 / 1-100 次)、停用时归一化; handleOpenAI403 在 HTML 403 守卫之后读取设置,三个数值全部改走配置; 新增 GET/PUT /api/v1/admin/settings/openai-403-cooldown。 - 前端:设置页「网关」分组内新增「OpenAI 403 临时冷却」卡片,中英文案。 默认值锁定为原常量(启用 / 10 / 3 / 180),未改设置的部署升级后行为 逐字不变;原三个常量保留作为唯一默认值来源,Kimi 并发限制路径借用 openAI403CooldownMinutesDefault 的逻辑不受影响。 enabled=false 的语义是「403 只换号、不罚号」:不递增计数、不临时停调、 不写永久错误,与既有 HTML 403(端点级错误)分支的处置一致。 附 10 个单元测试:设置读写默认 / clamp / 校验 / 归一化各一,处理链上 冷却时长 / 阈值 / 窗口三项分别断言走配置值,停用跳过处罚、无设置服务 时回落默认、HTML 守卫仍先于设置分支各一。撤掉读取点或删掉停用分支时 对应测试实测失败。 Fixes #5280 --- .../handler/admin/setting_handler_runtime.go | 60 +++++ backend/internal/handler/dto/settings.go | 8 + backend/internal/server/routes/admin.go | 2 + backend/internal/service/domain_constants.go | 3 + .../service/openai_403_cooldown_test.go | 223 ++++++++++++++++++ backend/internal/service/ratelimit_service.go | 33 ++- backend/internal/service/setting_features.go | 74 ++++++ backend/internal/service/settings_view.go | 23 ++ frontend/src/api/admin/settings.ts | 28 +++ .../src/i18n/locales/en/admin/settings.ts | 14 ++ .../src/i18n/locales/zh/admin/settings.ts | 14 ++ frontend/src/views/admin/SettingsView.vue | 204 ++++++++++++++++ 12 files changed, 680 insertions(+), 6 deletions(-) create mode 100644 backend/internal/service/openai_403_cooldown_test.go diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go index eb89b5472cde..1c5daf716f17 100644 --- a/backend/internal/handler/admin/setting_handler_runtime.go +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -154,6 +154,66 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { }) } +// GetOpenAI403CooldownSettings 获取 OpenAI 403 临时冷却配置 +// GET /api/v1/admin/settings/openai-403-cooldown +func (h *SettingHandler) GetOpenAI403CooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetOpenAI403CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OpenAI403CooldownSettings{ + Enabled: settings.Enabled, + CooldownMinutes: settings.CooldownMinutes, + DisableThreshold: settings.DisableThreshold, + WindowMinutes: settings.WindowMinutes, + }) +} + +// UpdateOpenAI403CooldownSettingsRequest 更新 OpenAI 403 临时冷却配置请求 +type UpdateOpenAI403CooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` + DisableThreshold int `json:"disable_threshold"` + WindowMinutes int `json:"window_minutes"` +} + +// UpdateOpenAI403CooldownSettings 更新 OpenAI 403 临时冷却配置 +// PUT /api/v1/admin/settings/openai-403-cooldown +func (h *SettingHandler) UpdateOpenAI403CooldownSettings(c *gin.Context) { + var req UpdateOpenAI403CooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.OpenAI403CooldownSettings{ + Enabled: req.Enabled, + CooldownMinutes: req.CooldownMinutes, + DisableThreshold: req.DisableThreshold, + WindowMinutes: req.WindowMinutes, + } + + if err := h.settingService.SetOpenAI403CooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetOpenAI403CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.OpenAI403CooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownMinutes: updatedSettings.CooldownMinutes, + DisableThreshold: updatedSettings.DisableThreshold, + WindowMinutes: updatedSettings.WindowMinutes, + }) +} + // 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 0d0c1ed40fcb..ac6dce48eb54 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -448,6 +448,14 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +// OpenAI403CooldownSettings OpenAI 403 临时冷却配置 DTO +type OpenAI403CooldownSettings struct { + Enabled bool `json:"enabled"` + CooldownMinutes int `json:"cooldown_minutes"` + DisableThreshold int `json:"disable_threshold"` + WindowMinutes int `json:"window_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 d618fb45e718..88ab7549b9eb 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -573,6 +573,8 @@ 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) + adminSettings.GET("/openai-403-cooldown", h.Admin.Setting.GetOpenAI403CooldownSettings) + adminSettings.PUT("/openai-403-cooldown", h.Admin.Setting.UpdateOpenAI403CooldownSettings) // 面板 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 4679823777cf..bf88579da828 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -537,6 +537,9 @@ const ( // SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling. SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings" + + // SettingKeyOpenAI403CooldownSettings stores JSON config for OpenAI 403 temporary cooldown handling. + SettingKeyOpenAI403CooldownSettings = "openai_403_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_403_cooldown_test.go b/backend/internal/service/openai_403_cooldown_test.go new file mode 100644 index 000000000000..7227eec6cdd2 --- /dev/null +++ b/backend/internal/service/openai_403_cooldown_test.go @@ -0,0 +1,223 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type openAI403TempRecorder struct { + *rateLimitAccountRepoStub + until time.Time +} + +func (r *openAI403TempRecorder) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error { + r.until = until + return r.rateLimitAccountRepoStub.SetTempUnschedulable(ctx, id, until, reason) +} + +type openAI403WindowRecorder struct { + *countingOpenAI403CounterCache + windows []int +} + +func (r *openAI403WindowRecorder) IncrementOpenAI403Count(ctx context.Context, accountID int64, window int) (int64, error) { + r.windows = append(r.windows, window) + return r.countingOpenAI403CounterCache.IncrementOpenAI403Count(ctx, accountID, window) +} + +func setOpenAI403TestSettings(t *testing.T, h *openAI403TestHarness, settings OpenAI403CooldownSettings) { + t.Helper() + repo := newMockSettingRepo() + data, err := json.Marshal(settings) + require.NoError(t, err) + repo.data[SettingKeyOpenAI403CooldownSettings] = string(data) + h.svc.SetSettingService(NewSettingService(repo, &config.Config{})) +} + +func TestGetOpenAI403CooldownSettings_DefaultsWhenNotSet(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + settings, err := svc.GetOpenAI403CooldownSettings(context.Background()) + require.NoError(t, err) + // MUTATION-SANITY: changing any OpenAI 403 default constant makes these assertions fail. + require.True(t, settings.Enabled) + require.Equal(t, 10, settings.CooldownMinutes) + require.Equal(t, 3, settings.DisableThreshold) + require.Equal(t, 180, settings.WindowMinutes) +} + +func TestGetOpenAI403CooldownSettings_ClampsOutOfRange(t *testing.T) { + repo := newMockSettingRepo() + repo.data[SettingKeyOpenAI403CooldownSettings] = `{"enabled":true,"cooldown_minutes":99999,"disable_threshold":0,"window_minutes":-5}` + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetOpenAI403CooldownSettings(context.Background()) + require.NoError(t, err) + // MUTATION-SANITY: removing the read-side clamps returns 99999, 0, and -5 here. + require.Equal(t, 1440, settings.CooldownMinutes) + require.Equal(t, 1, settings.DisableThreshold) + require.Equal(t, 1, settings.WindowMinutes) +} + +func TestSetOpenAI403CooldownSettings_RejectsOutOfRangeWhenEnabled(t *testing.T) { + tests := []struct { + name string + settings OpenAI403CooldownSettings + field string + }{ + { + name: "cooldown_minutes", + settings: OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 0, DisableThreshold: 3, WindowMinutes: 180, + }, + field: "cooldown_minutes", + }, + { + name: "disable_threshold", + settings: OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 10, DisableThreshold: 101, WindowMinutes: 180, + }, + field: "disable_threshold", + }, + { + name: "window_minutes", + settings: OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 10, DisableThreshold: 3, WindowMinutes: 1441, + }, + field: "window_minutes", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + err := svc.SetOpenAI403CooldownSettings(context.Background(), &test.settings) + + // MUTATION-SANITY: removing the enabled-state validation makes this call succeed. + require.Error(t, err) + require.Contains(t, err.Error(), test.field) + }) + } +} + +func TestSetOpenAI403CooldownSettings_NormalizesWhenDisabled(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + settings := &OpenAI403CooldownSettings{ + Enabled: false, + CooldownMinutes: 0, + DisableThreshold: 101, + WindowMinutes: -1, + } + + err := svc.SetOpenAI403CooldownSettings(context.Background(), settings) + require.NoError(t, err) + stored, err := svc.GetOpenAI403CooldownSettings(context.Background()) + require.NoError(t, err) + // MUTATION-SANITY: changing the disabled branch to reject invalid values fails before this normalized result. + require.False(t, stored.Enabled) + require.Equal(t, 10, stored.CooldownMinutes) + require.Equal(t, 3, stored.DisableThreshold) + require.Equal(t, 180, stored.WindowMinutes) +} + +func TestHandleOpenAI403_UsesConfiguredCooldownMinutes(t *testing.T) { + h := newOpenAI403TestHarness(t, 601, 1) + setOpenAI403TestSettings(t, h, OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 2, DisableThreshold: 3, WindowMinutes: 180, + }) + recorder := &openAI403TempRecorder{rateLimitAccountRepoStub: h.repo} + h.svc.accountRepo = recorder + before := time.Now() + + require.True(t, h.handle(`{"error":{"message":"temporary edge rejection"}}`)) + + // MUTATION-SANITY: replacing the configured cooldown with the 10-minute constant misses this window. + require.WithinDuration(t, before.Add(2*time.Minute), recorder.until, 5*time.Second) + require.Less(t, recorder.until.Sub(before), 5*time.Minute) +} + +func TestHandleOpenAI403_UsesConfiguredThreshold(t *testing.T) { + t.Run("at_threshold_disables", func(t *testing.T) { + h := newOpenAI403TestHarness(t, 602, 2) + setOpenAI403TestSettings(t, h, OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 10, DisableThreshold: 2, WindowMinutes: 180, + }) + + require.True(t, h.handle(`{"error":{"message":"workspace forbidden"}}`)) + // MUTATION-SANITY: comparing against the original threshold of 3 leaves this account temporary instead. + require.Equal(t, 1, h.repo.setErrorCalls) + require.Equal(t, 0, h.repo.tempCalls) + }) + + t.Run("below_threshold_is_temporary", func(t *testing.T) { + h := newOpenAI403TestHarness(t, 603, 1) + setOpenAI403TestSettings(t, h, OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 10, DisableThreshold: 2, WindowMinutes: 180, + }) + + require.True(t, h.handle(`{"error":{"message":"temporary edge rejection"}}`)) + // MUTATION-SANITY: reversing the configured threshold comparison permanently disables this account. + require.Equal(t, 0, h.repo.setErrorCalls) + require.Equal(t, 1, h.repo.tempCalls) + }) +} + +func TestHandleOpenAI403_PassesConfiguredWindowToCounter(t *testing.T) { + h := newOpenAI403TestHarness(t, 604, 1) + setOpenAI403TestSettings(t, h, OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 10, DisableThreshold: 3, WindowMinutes: 30, + }) + counter := &openAI403WindowRecorder{countingOpenAI403CounterCache: h.counter} + h.svc.SetOpenAI403CounterCache(counter) + + require.True(t, h.handle(`{"error":{"message":"temporary edge rejection"}}`)) + + // MUTATION-SANITY: passing the original 180-minute constant records 180 instead of 30. + require.Equal(t, []int{30}, counter.windows) +} + +func TestHandleOpenAI403_DisabledSkipsAccountPenalty(t *testing.T) { + h := newOpenAI403TestHarness(t, 605, 1) + setOpenAI403TestSettings(t, h, OpenAI403CooldownSettings{ + Enabled: false, CooldownMinutes: 10, DisableThreshold: 3, WindowMinutes: 180, + }) + + // MUTATION-SANITY: deleting the disabled guard returns true and records counter and penalty calls. + require.False(t, h.handle(`{"error":{"message":"temporary edge rejection"}}`)) + h.requireNoAccountPenalty(t) +} + +func TestHandleOpenAI403_FallsBackToDefaultsWithoutSettingService(t *testing.T) { + h := newOpenAI403TestHarness(t, 606, 1) + recorder := &openAI403TempRecorder{rateLimitAccountRepoStub: h.repo} + h.svc.accountRepo = recorder + counter := &openAI403WindowRecorder{countingOpenAI403CounterCache: h.counter} + h.svc.SetOpenAI403CounterCache(counter) + before := time.Now() + + require.True(t, h.handle(`{"error":{"message":"temporary edge rejection"}}`)) + + // MUTATION-SANITY: breaking the no-service fallback changes the 10-minute duration or 180-minute window. + require.WithinDuration(t, before.Add(10*time.Minute), recorder.until, 5*time.Second) + require.Equal(t, []int{180}, counter.windows) +} + +func TestHandleOpenAI403_HTMLBodyStillSkipsPenaltyWhenEnabled(t *testing.T) { + h := newOpenAI403TestHarness(t, 607, 1) + setOpenAI403TestSettings(t, h, OpenAI403CooldownSettings{ + Enabled: true, CooldownMinutes: 10, DisableThreshold: 3, WindowMinutes: 180, + }) + + // MUTATION-SANITY: moving the settings branch ahead of the HTML guard allows account penalty side effects. + require.False(t, h.handle(openAI403HTMLBody)) + h.requireNoAccountPenalty(t) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 4d6be56b06fa..e534b069ec99 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -89,6 +89,9 @@ const ( openAI403CooldownMinutesDefault = 10 openAI403DisableThreshold = 3 openAI403CounterWindowMinutes = 180 + maxOpenAI403CooldownMinutes = 1440 + maxOpenAI403DisableThreshold = 100 + maxOpenAI403WindowMinutes = 1440 ) // NewRateLimitService 创建RateLimitService实例 @@ -985,6 +988,12 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account return false } + settings := s.getOpenAI403CooldownSettings(ctx, account) + if !settings.Enabled { + slog.Info("openai_403_cooldown_disabled_skip_account_penalty", "account_id", account.ID) + return false + } + msg := buildForbiddenErrorMessage( "Access forbidden (403):", upstreamMsg, @@ -997,21 +1006,21 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account return true } - count, err := s.openAI403CounterCache.IncrementOpenAI403Count(ctx, account.ID, openAI403CounterWindowMinutes) + count, err := s.openAI403CounterCache.IncrementOpenAI403Count(ctx, account.ID, settings.WindowMinutes) if err != nil { slog.Warn("openai_403_increment_failed", "account_id", account.ID, "error", err) s.handleAuthError(ctx, account, msg) return true } - if count >= openAI403DisableThreshold { - msg = fmt.Sprintf("%s | consecutive_403=%d/%d", msg, count, openAI403DisableThreshold) + if count >= int64(settings.DisableThreshold) { + msg = fmt.Sprintf("%s | consecutive_403=%d/%d", msg, count, settings.DisableThreshold) s.handleAuthError(ctx, account, msg) return true } - until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute) - reason := fmt.Sprintf("OpenAI 403 temporary cooldown (%d/%d): %s", count, openAI403DisableThreshold, msg) + until := time.Now().Add(time.Duration(settings.CooldownMinutes) * time.Minute) + reason := fmt.Sprintf("OpenAI 403 temporary cooldown (%d/%d): %s", count, settings.DisableThreshold, msg) s.notifyAccountSchedulingBlocked(account, until, "openai_403_temp") if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil { slog.Warn("openai_403_set_temp_unschedulable_failed", "account_id", account.ID, "error", err) @@ -1024,11 +1033,23 @@ func (s *RateLimitService) handleOpenAI403(ctx context.Context, account *Account "account_id", account.ID, "until", until, "count", count, - "threshold", openAI403DisableThreshold, + "threshold", settings.DisableThreshold, + "cooldown_minutes", settings.CooldownMinutes, ) return true } +func (s *RateLimitService) getOpenAI403CooldownSettings(ctx context.Context, account *Account) *OpenAI403CooldownSettings { + if s.settingService != nil { + settings, err := s.settingService.GetOpenAI403CooldownSettings(ctx) + if err == nil && settings != nil { + return settings + } + slog.Warn("openai_403_cooldown_settings_read_failed", "account_id", account.ID, "error", err) + } + return DefaultOpenAI403CooldownSettings() +} + // handleAntigravity403 处理 Antigravity 平台的 403 错误 // validation(需要验证)→ 永久 SetError(需人工去 Google 验证后恢复) // violation(违规封号)→ 永久 SetError(需人工处理) diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 648fa1dc21b3..d4f3e47a253b 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -764,6 +764,80 @@ func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, se return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) } +// GetOpenAI403CooldownSettings 获取 OpenAI 403 临时冷却配置 +func (s *SettingService) GetOpenAI403CooldownSettings(ctx context.Context) (*OpenAI403CooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAI403CooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultOpenAI403CooldownSettings(), nil + } + return nil, fmt.Errorf("get OpenAI 403 cooldown settings: %w", err) + } + if value == "" { + return DefaultOpenAI403CooldownSettings(), nil + } + + var settings OpenAI403CooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultOpenAI403CooldownSettings(), nil + } + + if settings.CooldownMinutes < 1 { + settings.CooldownMinutes = 1 + } + if settings.CooldownMinutes > maxOpenAI403CooldownMinutes { + settings.CooldownMinutes = maxOpenAI403CooldownMinutes + } + if settings.DisableThreshold < 1 { + settings.DisableThreshold = 1 + } + if settings.DisableThreshold > maxOpenAI403DisableThreshold { + settings.DisableThreshold = maxOpenAI403DisableThreshold + } + if settings.WindowMinutes < 1 { + settings.WindowMinutes = 1 + } + if settings.WindowMinutes > maxOpenAI403WindowMinutes { + settings.WindowMinutes = maxOpenAI403WindowMinutes + } + + return &settings, nil +} + +// SetOpenAI403CooldownSettings 设置 OpenAI 403 临时冷却配置 +func (s *SettingService) SetOpenAI403CooldownSettings(ctx context.Context, settings *OpenAI403CooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + defaults := DefaultOpenAI403CooldownSettings() + if settings.CooldownMinutes < 1 || settings.CooldownMinutes > maxOpenAI403CooldownMinutes { + if settings.Enabled { + return fmt.Errorf("cooldown_minutes must be between 1-1440") + } + settings.CooldownMinutes = defaults.CooldownMinutes + } + if settings.DisableThreshold < 1 || settings.DisableThreshold > maxOpenAI403DisableThreshold { + if settings.Enabled { + return fmt.Errorf("disable_threshold must be between 1-100") + } + settings.DisableThreshold = defaults.DisableThreshold + } + if settings.WindowMinutes < 1 || settings.WindowMinutes > maxOpenAI403WindowMinutes { + if settings.Enabled { + return fmt.Errorf("window_minutes must be between 1-1440") + } + settings.WindowMinutes = defaults.WindowMinutes + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal OpenAI 403 cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyOpenAI403CooldownSettings, 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 25d2b313e1be..0925fb05d74e 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -565,6 +565,19 @@ type RateLimit429CooldownSettings struct { CooldownSeconds int `json:"cooldown_seconds"` } +// OpenAI403CooldownSettings OpenAI 403 临时冷却配置 +type OpenAI403CooldownSettings struct { + // Enabled 是否对 OpenAI 403 启用计数式临时冷却。关闭时 403 只触发换号(failover), + // 不递增计数、不临时停调、不永久禁用账号——与 HTML 403(端点级错误)分支的处置一致。 + Enabled bool `json:"enabled"` + // CooldownMinutes 单次临时冷却时长(分钟),范围 1-1440 + CooldownMinutes int `json:"cooldown_minutes"` + // DisableThreshold 计数窗口内累计到该次数即转为永久错误状态,范围 1-100 + DisableThreshold int `json:"disable_threshold"` + // WindowMinutes 连续 403 计数窗口(分钟),范围 1-1440 + WindowMinutes int `json:"window_minutes"` +} + // OpenAIAPIKeyHealthBreakerSettings controls cross-instance failure counting for OpenAI pool API keys. type OpenAIAPIKeyHealthBreakerSettings struct { Enabled bool `json:"enabled"` @@ -598,6 +611,16 @@ func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings { } } +// DefaultOpenAI403CooldownSettings 返回默认的 OpenAI 403 冷却配置(启用,10 分钟 / 3 次 / 180 分钟窗口) +func DefaultOpenAI403CooldownSettings() *OpenAI403CooldownSettings { + return &OpenAI403CooldownSettings{ + Enabled: true, + CooldownMinutes: openAI403CooldownMinutesDefault, + DisableThreshold: openAI403DisableThreshold, + WindowMinutes: openAI403CounterWindowMinutes, + } +} + // DefaultBetaPolicySettings 返回默认的 Beta 策略配置 // // context-1m-2025-08-07 的默认策略: diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4ceb75cbd944..d8692ae2ced9 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1310,6 +1310,32 @@ export async function updateRateLimit429CooldownSettings( return data; } +// ==================== OpenAI 403 Cooldown Settings ==================== + +export interface OpenAI403CooldownSettings { + enabled: boolean; + cooldown_minutes: number; + disable_threshold: number; + window_minutes: number; +} + +export async function getOpenAI403CooldownSettings(): Promise { + const { data } = await apiClient.get( + "/admin/settings/openai-403-cooldown", + ); + return data; +} + +export async function updateOpenAI403CooldownSettings( + settings: OpenAI403CooldownSettings, +): Promise { + const { data } = await apiClient.put( + "/admin/settings/openai-403-cooldown", + settings, + ); + return data; +} + // ==================== Panel Rate Limit Settings ==================== /** @@ -1569,6 +1595,8 @@ export const settingsAPI = { updateOverloadCooldownSettings, getRateLimit429CooldownSettings, updateRateLimit429CooldownSettings, + getOpenAI403CooldownSettings, + updateOpenAI403CooldownSettings, getPanelRateLimitSettings, updatePanelRateLimitSettings, getStreamTimeoutSettings, diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 4c23f239ae8c..a7b22ef54e98 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -1004,6 +1004,20 @@ export default { saved: '429 default cooldown settings saved', saveFailed: 'Failed to save 429 default cooldown settings' }, + openAI403Cooldown: { + title: 'OpenAI 403 Temporary Cooldown', + description: 'Configure count-based temporary cooldowns for OpenAI accounts that receive 403 responses, escalating repeated failures to a permanent error', + enabled: 'Enable 403 Temporary Cooldown', + enabledHint: 'When disabled, a 403 only triggers failover without pausing, counting, or disabling the account', + cooldownMinutes: 'Cooldown Duration (minutes)', + cooldownMinutesHint: 'Duration to pause account scheduling after a 403 (1-1440 minutes, default 10)', + disableThreshold: 'Disable Threshold (count)', + disableThresholdHint: 'Mark the account as errored after this many 403 responses within the counting window (1-100, default 3)', + windowMinutes: 'Counting Window (minutes)', + windowMinutesHint: 'Time window used to count consecutive 403 responses (1-1440 minutes, default 180)', + saved: '403 cooldown settings saved', + saveFailed: 'Failed to save 403 cooldown settings' + }, streamTimeout: { title: 'Stream Timeout Handling', description: 'Configure account handling strategy when upstream response times out', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index fb5371e8c51d..99e548dc07d0 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -998,6 +998,20 @@ export default { saved: '429 默认回避设置保存成功', saveFailed: '保存 429 默认回避设置失败' }, + openAI403Cooldown: { + title: 'OpenAI 403 临时冷却', + description: '配置 OpenAI 账号收到 403 时的计数式临时冷却策略(连续多次后转为永久错误)', + enabled: '启用 403 临时冷却', + enabledHint: '关闭后 403 仅触发换号,不再暂停调度、不计数、不禁用账号', + cooldownMinutes: '单次冷却时长(分钟)', + cooldownMinutesHint: '收到 403 后账号暂停调度的时长(1-1440 分钟,默认 10)', + disableThreshold: '禁用阈值(次)', + disableThresholdHint: '计数窗口内累计达到该次数即标记账号为错误状态(1-100 次,默认 3)', + windowMinutes: '计数窗口(分钟)', + windowMinutesHint: '连续 403 的统计窗口(1-1440 分钟,默认 180)', + saved: '403 冷却设置保存成功', + saveFailed: '保存 403 冷却设置失败' + }, streamTimeout: { title: '流超时处理', description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 7d68bcb87b44..904e7208535b 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -411,6 +411,163 @@ + +
+
+

+ {{ t("admin.settings.openAI403Cooldown.title") }} +

+

+ {{ t("admin.settings.openAI403Cooldown.description") }} +

+
+
+
+
+ {{ t("common.loading") }} +
+ + +
+
+
{ loadOllamaCloudUsageSettings(); loadOverloadCooldownSettings(); loadRateLimit429CooldownSettings(); + loadOpenAI403CooldownSettings(); loadPanelRateLimitSettings(); loadStreamTimeoutSettings(); loadRectifierSettings();