Merge pull request #6254 from wucm667/fix/issue-6249-configurable-image-cooldown

feat(openai): configure image tool cooldown
This commit is contained in:
Wesley Liddick
2026-08-31 11:13:52 +08:00
committed by GitHub
9 changed files with 173 additions and 4 deletions
@@ -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) {
+4
View File
@@ -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"`
+3
View File
@@ -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)
@@ -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"
@@ -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)
}
}
@@ -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
@@ -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) {
@@ -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)
+14
View File
@@ -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 的默认策略: