From b18e4ce455282e73e1833a9fe50396a9279d616b Mon Sep 17 00:00:00 2001 From: Brisbanehuang <202969697+Brisbanehuang@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:14:16 -0400 Subject: [PATCH] =?UTF-8?q?fix(scheduler):=20=E4=B8=BA=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=80=8D=E7=8E=87=E8=A1=A5=E5=85=85=E8=B4=A6=E5=8F=B7=E5=80=8D?= =?UTF-8?q?=E7=8E=87=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/admin/setting_handler_audit.go | 2 +- .../admin/setting_handler_oauth_rate_test.go | 76 +++++++++++ .../handler/admin/setting_handler_update.go | 7 +- backend/internal/handler/dto/settings.go | 54 ++++---- .../service/openai_account_scheduler.go | 36 +++-- ...ai_account_scheduler_upstream_cost_test.go | 47 +++---- .../openai_scheduling_rate_fallback_test.go | 125 ++++++++++++++++++ backend/internal/service/setting_parse.go | 16 ++- .../service/setting_service_update_test.go | 12 +- backend/internal/service/setting_update.go | 5 +- backend/internal/service/settings_view.go | 2 +- frontend/src/api/admin/settings.ts | 6 +- .../src/i18n/locales/en/admin/settings.ts | 5 +- .../src/i18n/locales/zh/admin/settings.ts | 5 +- frontend/src/views/admin/SettingsView.vue | 24 +++- .../admin/__tests__/SettingsView.spec.ts | 59 ++++++++- 16 files changed, 387 insertions(+), 94 deletions(-) create mode 100644 backend/internal/handler/admin/setting_handler_oauth_rate_test.go create mode 100644 backend/internal/service/openai_scheduling_rate_fallback_test.go diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index f888d53adf..edeeba61d0 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -506,7 +506,7 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAILowUpstreamRatePriorityEnabled != after.OpenAILowUpstreamRatePriorityEnabled { changed = append(changed, "openai_low_upstream_rate_priority_enabled") } - if before.OpenAIOAuthSchedulingRateMultiplier != after.OpenAIOAuthSchedulingRateMultiplier { + if !equalNullableFloat(before.OpenAIOAuthSchedulingRateMultiplier, after.OpenAIOAuthSchedulingRateMultiplier) { changed = append(changed, "openai_oauth_scheduling_rate_multiplier") } if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled { diff --git a/backend/internal/handler/admin/setting_handler_oauth_rate_test.go b/backend/internal/handler/admin/setting_handler_oauth_rate_test.go new file mode 100644 index 0000000000..5c643d0f4e --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_oauth_rate_test.go @@ -0,0 +1,76 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSettingHandler_OAuthSchedulingRateRoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + initial map[string]string + body string + stored string + value any + status int + }{ + {"absent keeps default", nil, `{}`, "1", float64(1), http.StatusOK}, + {"omitted preserves override", map[string]string{service.SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.7"}, `{}`, "0.7", 0.7, http.StatusOK}, + {"clear override", map[string]string{service.SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.7"}, `{"openai_oauth_scheduling_rate_multiplier":null}`, "", nil, http.StatusOK}, + {"omitted preserves cleared", map[string]string{service.SettingKeyOpenAIOAuthSchedulingRateMultiplier: ""}, `{}`, "", nil, http.StatusOK}, + {"set override", nil, `{"openai_oauth_scheduling_rate_multiplier":0.7}`, "0.7", 0.7, http.StatusOK}, + {"zero is explicit", nil, `{"openai_oauth_scheduling_rate_multiplier":0}`, "0", float64(0), http.StatusOK}, + {"negative is rejected", nil, `{"openai_oauth_scheduling_rate_multiplier":-1}`, "", nil, http.StatusBadRequest}, + {"string is rejected", nil, `{"openai_oauth_scheduling_rate_multiplier":"invalid"}`, "", nil, http.StatusBadRequest}, + } { + t.Run(tt.name, func(t *testing.T) { + repo := &settingHandlerRepoStub{values: tt.initial} + svc := service.NewSettingService(repo, &config.Config{}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewBufferString(tt.body)) + c.Request.Header.Set("Content-Type", "application/json") + handler.UpdateSettings(c) + require.Equal(t, tt.status, rec.Code, rec.Body.String()) + if tt.status != http.StatusOK { + require.Empty(t, repo.lastUpdates) + return + } + require.Equal(t, tt.stored, repo.values[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + var body struct { + Data map[string]any `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Contains(t, body.Data, service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, tt.value, body.Data[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + + getRec := httptest.NewRecorder() + getContext, _ := gin.CreateTestContext(getRec) + getContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) + handler.GetSettings(getContext) + require.Equal(t, http.StatusOK, getRec.Code, getRec.Body.String()) + require.NoError(t, json.Unmarshal(getRec.Body.Bytes(), &body)) + require.Contains(t, body.Data, service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, tt.value, body.Data[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + }) + } +} + +func TestSettingsAuditTracksOAuthRateValueNotPointer(t *testing.T) { + beforeRate, afterRate := 0.7, 0.7 + before := &service.SystemSettings{OpenAIOAuthSchedulingRateMultiplier: &beforeRate} + after := &service.SystemSettings{OpenAIOAuthSchedulingRateMultiplier: &afterRate} + require.NotContains(t, diffSettings(before, after, nil, nil, UpdateSettingsRequest{}), service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) + after.OpenAIOAuthSchedulingRateMultiplier = nil + require.Contains(t, diffSettings(before, after, nil, nil, UpdateSettingsRequest{}), service.SettingKeyOpenAIOAuthSchedulingRateMultiplier) +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 3f20bbe1f6..2143cb1f28 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -1809,9 +1809,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAILowUpstreamRatePriorityEnabled }(), - OpenAIOAuthSchedulingRateMultiplier: func() float64 { - if req.OpenAIOAuthSchedulingRateMultiplier != nil { - return *req.OpenAIOAuthSchedulingRateMultiplier + OpenAIOAuthSchedulingRateMultiplier: func() *float64 { + // Omitted fields preserve the override; explicit null clears it. + if _, sent := sentFields[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier]; sent { + return req.OpenAIOAuthSchedulingRateMultiplier } return previousSettings.OpenAIOAuthSchedulingRateMultiplier }(), diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index b0f4ebed70..cac990e520 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -236,33 +236,33 @@ type SystemSettings struct { PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"` // OpenAI account scheduling - OpenAILowUpstreamRatePriorityEnabled bool `json:"openai_low_upstream_rate_priority_enabled"` - OpenAIOAuthSchedulingRateMultiplier float64 `json:"openai_oauth_scheduling_rate_multiplier"` - OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"` - OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"` - OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"` - OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"` - OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"` - OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"` - OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"` - OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"` - OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"` - OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"` - OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"` - OpenAIAdvancedSchedulerWeightUpstreamCost string `json:"openai_advanced_scheduler_weight_upstream_cost"` - OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"` - OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"` - OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"` - OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"` - OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"` - OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"` - OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"` - OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"` - OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"` - OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"` - OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost string `json:"openai_advanced_scheduler_effective_weight_upstream_cost"` - OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"` - OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"` + OpenAILowUpstreamRatePriorityEnabled bool `json:"openai_low_upstream_rate_priority_enabled"` + OpenAIOAuthSchedulingRateMultiplier *float64 `json:"openai_oauth_scheduling_rate_multiplier"` + OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"` + OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"` + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"` + OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"` + OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"` + OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"` + OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"` + OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"` + OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"` + OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"` + OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"` + OpenAIAdvancedSchedulerWeightUpstreamCost string `json:"openai_advanced_scheduler_weight_upstream_cost"` + OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"` + OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"` + OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"` + OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"` + OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"` + OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"` + OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"` + OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"` + OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"` + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"` + OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost string `json:"openai_advanced_scheduler_effective_weight_upstream_cost"` + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"` + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"` // Payment configuration PaymentEnabled bool `json:"payment_enabled"` diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 5ad63d30f8..928e2624d2 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -45,7 +45,7 @@ const ( type cachedOpenAIAdvancedSchedulerSetting struct { lowUpstreamRatePriorityEnabled bool - oauthSchedulingRateMultiplier float64 + oauthSchedulingRateMultiplier *float64 enabled bool stickyWeightedEnabled bool subscriptionPriorityEnabled bool @@ -56,7 +56,7 @@ type cachedOpenAIAdvancedSchedulerSetting struct { type openAIAdvancedSchedulerRuntimeSettings struct { lowUpstreamRatePriorityEnabled bool - oauthSchedulingRateMultiplier float64 + oauthSchedulingRateMultiplier *float64 enabled bool stickyWeightedEnabled bool subscriptionPriorityEnabled bool @@ -1904,7 +1904,7 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex } lowUpstreamRatePriorityEnabled := false - oauthSchedulingRateMultiplier := defaultOpenAIOAuthSchedulingRateMultiplier + oauthSchedulingRateMultiplier := parseOpenAIOAuthSchedulingRateMultiplier(nil) enabled := false stickyWeightedEnabled := false subscriptionPriorityEnabled := false @@ -1916,7 +1916,7 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex if values, err := repo.GetMultiple(dbCtx, openAIAdvancedSchedulerRuntimeSettingKeys()); err == nil { lowUpstreamRatePriorityEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAILowUpstreamRatePriorityEnabled]), "true") - oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(values[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(values) enabled = strings.EqualFold(strings.TrimSpace(values[openAIAdvancedSchedulerSettingKey]), "true") stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true") subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true") @@ -1933,7 +1933,7 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx contex } } lowUpstreamRatePriorityEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAILowUpstreamRatePriorityEnabled]), "true") - oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(fallbackValues[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + oauthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(fallbackValues) enabled = strings.EqualFold(strings.TrimSpace(fallbackValues[openAIAdvancedSchedulerSettingKey]), "true") stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true") subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(fallbackValues[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true") @@ -1976,7 +1976,7 @@ func (s *OpenAIGatewayService) isOpenAILowUpstreamRatePriorityEnabled(ctx contex return !settings.enabled && settings.lowUpstreamRatePriorityEnabled } -func (s *OpenAIGatewayService) openAIOAuthSchedulingRateMultiplier(ctx context.Context) float64 { +func (s *OpenAIGatewayService) openAIOAuthSchedulingRateMultiplier(ctx context.Context) *float64 { return s.openAIAdvancedSchedulerRuntimeSettings(ctx).oauthSchedulingRateMultiplier } @@ -2664,7 +2664,7 @@ func BuildOpenAIAccountSchedulerScoreSnapshot( loadMap map[int64]*AccountLoadInfo, ) map[int64]OpenAIAccountSchedulerScoreSnapshot { gateway := &OpenAIGatewayService{} - return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier) + return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeights(), false, parseOpenAIOAuthSchedulingRateMultiplier(nil)) } func buildOpenAIAccountSchedulerScoreSnapshot( @@ -2672,7 +2672,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot( loadMap map[int64]*AccountLoadInfo, weights GatewayOpenAIWSSchedulerScoreWeightsView, stickyWeightedEnabled bool, - oauthSchedulingRateMultiplier float64, + oauthSchedulingRateMultiplier *float64, ) map[int64]OpenAIAccountSchedulerScoreSnapshot { if len(accounts) == 0 { return nil @@ -2795,7 +2795,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot( return result } -func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier float64) map[int64]float64 { +func openAIUpstreamCostFactors(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier *float64) map[int64]float64 { type rateSample struct { accountID int64 rate float64 @@ -2862,7 +2862,7 @@ type openAILegacyUpstreamRateOrder struct { rates map[int64]float64 } -func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier float64) openAILegacyUpstreamRateOrder { +func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthSchedulingRateMultiplier *float64) openAILegacyUpstreamRateOrder { rates := make(map[int64]float64, len(accounts)) var first float64 distinct := false @@ -2890,11 +2890,19 @@ func newOpenAILegacyUpstreamRateOrder(accounts []*Account, now time.Time, oauthS return openAILegacyUpstreamRateOrder{enabled: len(rates) >= 2 && distinct, rates: rates} } -func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier float64) (float64, bool) { - if account != nil && account.IsOpenAIOAuthLike() { - return oauthSchedulingRateMultiplier, true +func openAISchedulingRate(account *Account, now time.Time, oauthSchedulingRateMultiplier *float64) (float64, bool) { + if account == nil || (!account.IsOpenAIApiKey() && !account.IsOpenAIOAuthLike()) { + return 0, false } - return openAIFreshUpstreamBillingRate(account, now) + if account.IsOpenAIOAuthLike() { + if rate := oauthSchedulingRateMultiplier; rate != nil && *rate >= 0 && !math.IsNaN(*rate) && !math.IsInf(*rate, 0) { + return *rate, true + } + } else if rate, ok := openAIFreshUpstreamBillingRate(account, now); ok { + return rate, true + } + rate := account.BillingRateMultiplier() + return rate, !math.IsNaN(rate) && !math.IsInf(rate, 0) } // compare returns -1 when a should be selected before b, 1 when b should be diff --git a/backend/internal/service/openai_account_scheduler_upstream_cost_test.go b/backend/internal/service/openai_account_scheduler_upstream_cost_test.go index 1288107573..8b402eeb25 100644 --- a/backend/internal/service/openai_account_scheduler_upstream_cost_test.go +++ b/backend/internal/service/openai_account_scheduler_upstream_cost_test.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "math" "strconv" "testing" "time" @@ -402,16 +401,17 @@ func TestOpenAIFreshUpstreamBillingRateRecomputesPeakAtSelectionTime(t *testing. "timezone": "UTC", } - duringPeak, ok := openAIFreshUpstreamBillingRate(account, time.Date(2026, 7, 13, 17, 59, 0, 0, time.UTC)) + account.RateMultiplier = floatPtr(0.1) + duringPeak, ok := openAISchedulingRate(account, time.Date(2026, 7, 13, 17, 59, 0, 0, time.UTC), nil) require.True(t, ok) require.Equal(t, 0.8, duringPeak) - afterPeak, ok := openAIFreshUpstreamBillingRate(account, time.Date(2026, 7, 13, 18, 1, 0, 0, time.UTC)) + afterPeak, ok := openAISchedulingRate(account, time.Date(2026, 7, 13, 18, 1, 0, 0, time.UTC), nil) require.True(t, ok) require.Equal(t, 0.4, afterPeak) } -func TestOpenAIUpstreamCostFactorsSparseProbeIsNeutral(t *testing.T) { +func TestOpenAIUpstreamCostFactorsEqualProbeAndFallbackRatesAreNeutral(t *testing.T) { now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) accounts := make([]*Account, 0, 10) accounts = append(accounts, upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1, now.Add(-time.Minute), 30*time.Minute)) @@ -430,13 +430,13 @@ func TestOpenAIUpstreamCostFactorsSparseProbeIsNeutral(t *testing.T) { }) } - factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier) + factors := openAIUpstreamCostFactors(accounts, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) for id := int64(1); id <= 10; id++ { require.Equal(t, openAIUpstreamCostNeutralFactor, factors[id]) } } -func TestOpenAIUpstreamCostFactorsCoverageShrinksSparseSignal(t *testing.T) { +func TestOpenAIUpstreamCostFactorsIncludeAccountFallbacksInCoverage(t *testing.T) { now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) accounts := []*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute), @@ -446,10 +446,10 @@ func TestOpenAIUpstreamCostFactorsCoverageShrinksSparseSignal(t *testing.T) { accounts = append(accounts, &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}) } - factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier) - center := math.Sqrt(0.03 * 0.8) - require.InDelta(t, 0.5+0.2*(1/(1+0.03/center)-0.5), factors[1], 1e-12) - require.InDelta(t, 0.5+0.2*(1/(1+0.8/center)-0.5), factors[2], 1e-12) + factors := openAIUpstreamCostFactors(accounts, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) + // Eight unprobed accounts default to 1x, so the median is 1 and coverage is complete. + require.InDelta(t, 1/(1+0.03), factors[1], 1e-12) + require.InDelta(t, 1/(1+0.8), factors[2], 1e-12) require.Equal(t, openAIUpstreamCostNeutralFactor, factors[3]) } @@ -461,7 +461,7 @@ func TestOpenAIUpstreamCostFactorsUseMedianAgainstOutlier(t *testing.T) { upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 100, now.Add(-time.Minute), 30*time.Minute), } - factors := openAIUpstreamCostFactors(accounts, now, defaultOpenAIOAuthSchedulingRateMultiplier) + factors := openAIUpstreamCostFactors(accounts, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.InDelta(t, 2.0/3.0, factors[1], 1e-12) require.InDelta(t, 0.5, factors[2], 1e-12) require.InDelta(t, 1/(1+100/0.2), factors[3], 1e-12) @@ -472,20 +472,21 @@ func TestOpenAILegacyUpstreamRateOrderRequiresComparableRates(t *testing.T) { oneKnown := newOpenAILegacyUpstreamRateOrder([]*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute), {ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - }, now, defaultOpenAIOAuthSchedulingRateMultiplier) - require.False(t, oneKnown.enabled) + }, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) + require.True(t, oneKnown.enabled) + require.Equal(t, 1.0, oneKnown.rates[2]) allEqual := newOpenAILegacyUpstreamRateOrder([]*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute), upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute), - }, now, defaultOpenAIOAuthSchedulingRateMultiplier) + }, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.False(t, allEqual.enabled) distinct := newOpenAILegacyUpstreamRateOrder([]*Account{ upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.03, now.Add(-time.Minute), 30*time.Minute), upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), {ID: 3, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, - }, now, defaultOpenAIOAuthSchedulingRateMultiplier) + }, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.True(t, distinct.enabled) require.Negative(t, distinct.compare(&Account{ID: 1}, &Account{ID: 2})) require.Negative(t, distinct.compare(&Account{ID: 2}, &Account{ID: 3})) @@ -506,12 +507,12 @@ func TestOpenAILegacyUpstreamRateOrderIgnoresNonOpenAIPlatforms(t *testing.T) { grokCheap := nonOpenAI(1, PlatformGrok, 0.01) anthropicExpensive := nonOpenAI(2, PlatformAnthropic, 0.9) - order := newOpenAILegacyUpstreamRateOrder([]*Account{grokCheap, anthropicExpensive, nil}, now, defaultOpenAIOAuthSchedulingRateMultiplier) + order := newOpenAILegacyUpstreamRateOrder([]*Account{grokCheap, anthropicExpensive, nil}, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.False(t, order.enabled) require.Empty(t, order.rates) require.Zero(t, order.compare(grokCheap, anthropicExpensive)) - factors := openAIUpstreamCostFactors([]*Account{grokCheap, anthropicExpensive}, now, defaultOpenAIOAuthSchedulingRateMultiplier) + factors := openAIUpstreamCostFactors([]*Account{grokCheap, anthropicExpensive}, now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.Equal(t, openAIUpstreamCostNeutralFactor, factors[grokCheap.ID]) require.Equal(t, openAIUpstreamCostNeutralFactor, factors[anthropicExpensive.ID]) @@ -520,7 +521,7 @@ func TestOpenAILegacyUpstreamRateOrderIgnoresNonOpenAIPlatforms(t *testing.T) { openAIExpensive := upstreamCostTestAccount(4, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute) mixed := newOpenAILegacyUpstreamRateOrder( []*Account{grokCheap, openAICheap, anthropicExpensive, openAIExpensive}, - now, defaultOpenAIOAuthSchedulingRateMultiplier, + now, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier), ) require.True(t, mixed.enabled) require.Len(t, mixed.rates, 2) @@ -537,12 +538,12 @@ func TestOpenAISchedulingRatePlacesOAuthAtConfiguredReference(t *testing.T) { oauth := upstreamCostTestOAuthAccount(2) expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.12, now.Add(-time.Minute), 30*time.Minute) - order := newOpenAILegacyUpstreamRateOrder([]*Account{cheap, oauth, expensive}, now, 0.05) + order := newOpenAILegacyUpstreamRateOrder([]*Account{cheap, oauth, expensive}, now, floatPtr(0.05)) require.True(t, order.enabled) require.Negative(t, order.compare(cheap, oauth)) require.Negative(t, order.compare(oauth, expensive)) - factors := openAIUpstreamCostFactors([]*Account{cheap, oauth, expensive}, now, 0.05) + factors := openAIUpstreamCostFactors([]*Account{cheap, oauth, expensive}, now, floatPtr(0.05)) require.Greater(t, factors[cheap.ID], factors[oauth.ID]) require.Greater(t, factors[oauth.ID], factors[expensive.ID]) } @@ -834,9 +835,9 @@ func TestBuildOpenAIAccountSchedulerScoreSnapshotUpstreamCostIsExactNoOpWithoutS 2: {AccountID: 2, LoadRate: 80}, } weights := GatewayOpenAIWSSchedulerScoreWeightsView{Priority: 1, Load: 1, Queue: 0.7, ErrorRate: 0.8, TTFT: 0.5} - baseline := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier) + baseline := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) weights.UpstreamCost = 1.5 - withCost := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier) + withCost := buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, weights, false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.Equal(t, baseline, withCost) } @@ -848,7 +849,7 @@ func TestBuildOpenAIAccountSchedulerScoreSnapshotUsesUpstreamCostSignal(t *testi upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), } weights := GatewayOpenAIWSSchedulerScoreWeightsView{UpstreamCost: 1.5} - scores := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, weights, false, defaultOpenAIOAuthSchedulingRateMultiplier) + scores := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, weights, false, floatPtr(defaultOpenAIOAuthSchedulingRateMultiplier)) require.Greater(t, scores[1].BaseScore, scores[2].BaseScore) } diff --git a/backend/internal/service/openai_scheduling_rate_fallback_test.go b/backend/internal/service/openai_scheduling_rate_fallback_test.go new file mode 100644 index 0000000000..86ab5add45 --- /dev/null +++ b/backend/internal/service/openai_scheduling_rate_fallback_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestOpenAISchedulingRateFallback(t *testing.T) { + now := time.Now() + for _, tt := range []struct { + name string + status string + age time.Duration + probe float64 + want float64 + }{ + {"fresh snapshot wins", UpstreamBillingProbeStatusOK, time.Minute, 0.6, 0.6}, + {"fresh snapshot survives failed refresh", UpstreamBillingProbeStatusFailed, time.Minute, 0.6, 0.6}, + {"expired snapshot falls back", UpstreamBillingProbeStatusOK, 3 * time.Hour, 0.6, 0.3}, + {"future snapshot falls back", UpstreamBillingProbeStatusOK, -time.Hour, 0.6, 0.3}, + {"invalid snapshot falls back", UpstreamBillingProbeStatusOK, time.Minute, -1, 0.3}, + {"zero snapshot remains valid", UpstreamBillingProbeStatusOK, time.Minute, 0, 0}, + } { + t.Run(tt.name, func(t *testing.T) { + account := upstreamCostTestAccount(1, tt.status, tt.probe, now.Add(-tt.age), time.Hour) + account.RateMultiplier = floatPtr(0.3) + rate, ok := openAISchedulingRate(account, now, floatPtr(0.9)) + require.True(t, ok) + require.Equal(t, tt.want, rate) + }) + } + + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + t.Run(accountType+" without probe or override", func(t *testing.T) { + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: accountType} + for _, tt := range []struct { + configured *float64 + want float64 + known bool + }{ + {floatPtr(0.3), 0.3, true}, + {floatPtr(0), 0, true}, + {nil, 1, true}, + {floatPtr(-1), 1, true}, + {floatPtr(math.NaN()), 0, false}, + {floatPtr(math.Inf(1)), 0, false}, + } { + account.RateMultiplier = tt.configured + rate, ok := openAISchedulingRate(account, now, nil) + require.Equal(t, tt.known, ok) + if ok { + require.Equal(t, tt.want, rate) + } + } + }) + } + + oauth := upstreamCostTestOAuthAccount(1) + oauth.RateMultiplier = floatPtr(0.3) + for _, override := range []float64{0, 0.7} { + rate, ok := openAISchedulingRate(oauth, now, &override) + require.True(t, ok) + require.Equal(t, override, rate) + } + for _, override := range []float64{-1, math.NaN(), math.Inf(1)} { + rate, ok := openAISchedulingRate(oauth, now, &override) + require.True(t, ok) + require.Equal(t, 0.3, rate) + } + _, ok := openAISchedulingRate(nil, now, nil) + require.False(t, ok) +} + +func TestOpenAISchedulingRateFallbackSharedByBothModes(t *testing.T) { + now := time.Now() + for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} { + t.Run(accountType, func(t *testing.T) { + cheap := &Account{ID: 1, Platform: PlatformOpenAI, Type: accountType, RateMultiplier: floatPtr(0.3)} + expensive := &Account{ID: 2, Platform: PlatformOpenAI, Type: accountType, RateMultiplier: floatPtr(0.8)} + accounts := []*Account{cheap, expensive} + order := newOpenAILegacyUpstreamRateOrder(accounts, now, nil) + require.True(t, order.enabled) + require.Negative(t, order.compare(cheap, expensive)) + factors := openAIUpstreamCostFactors(accounts, now, nil) + require.Greater(t, factors[cheap.ID], factors[expensive.ID]) + if accountType == AccountTypeOAuth { + order = newOpenAILegacyUpstreamRateOrder(accounts, now, floatPtr(0.7)) + require.False(t, order.enabled) + factors = openAIUpstreamCostFactors(accounts, now, floatPtr(0.7)) + require.Equal(t, factors[cheap.ID], factors[expensive.ID]) + } + }) + } +} + +func TestOpenAIOAuthSchedulingRateRuntimePreservesExplicitClear(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + for _, tt := range []struct { + name string + values map[string]string + want *float64 + }{ + {"absent", map[string]string{}, floatPtr(1)}, + {"cleared", map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: ""}, nil}, + {"zero", map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0"}, floatPtr(0)}, + {"override", map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.7"}, floatPtr(0.7)}, + } { + t.Run(tt.name, func(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + svc := &OpenAIGatewayService{ + rateLimitService: &RateLimitService{settingService: &SettingService{ + settingRepo: &openAIAdvancedSchedulerSettingRepoStub{values: tt.values}, + }}, + } + for i := 0; i < 2; i++ { + require.Equal(t, tt.want, svc.openAIOAuthSchedulingRateMultiplier(context.Background())) + } + }) + } +} diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index dc9366ba9e..d6497775cf 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -915,7 +915,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" result.OpenAILowUpstreamRatePriorityEnabled = settings[SettingKeyOpenAILowUpstreamRatePriorityEnabled] == "true" - result.OpenAIOAuthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(settings[SettingKeyOpenAIOAuthSchedulingRateMultiplier]) + result.OpenAIOAuthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(settings) result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" @@ -1080,7 +1080,7 @@ func formatOpenAIAdvancedSchedulerFloat(value float64) string { } func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { - if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate < 0 || math.IsNaN(rate) || math.IsInf(rate, 0) { + if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate != nil && (*rate < 0 || math.IsNaN(*rate) || math.IsInf(*rate, 0)) { return infraerrors.BadRequest("INVALID_OPENAI_OAUTH_SCHEDULING_RATE_MULTIPLIER", "OpenAI OAuth scheduling rate multiplier must be a finite non-negative number") } @@ -1132,12 +1132,18 @@ func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *Sys return nil } -func parseOpenAIOAuthSchedulingRateMultiplier(raw string) float64 { +func parseOpenAIOAuthSchedulingRateMultiplier(settings map[string]string) *float64 { + raw, exists := settings[SettingKeyOpenAIOAuthSchedulingRateMultiplier] + if !exists { + // Preserve the legacy default until an administrator explicitly clears it. + value := defaultOpenAIOAuthSchedulingRateMultiplier + return &value + } value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { - return defaultOpenAIOAuthSchedulingRateMultiplier + return nil } - return value + return &value } // resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。 diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 750f01728b..40619e29e4 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -410,7 +410,7 @@ func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler PaymentVisibleMethodAlipayEnabled: true, PaymentVisibleMethodWxpayEnabled: false, OpenAILowUpstreamRatePriorityEnabled: true, - OpenAIOAuthSchedulingRateMultiplier: 0.05, + OpenAIOAuthSchedulingRateMultiplier: testPtrFloat64(0.05), OpenAIAdvancedSchedulerEnabled: true, OpenAIAdvancedSchedulerStickyWeightedEnabled: true, OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: true, @@ -454,7 +454,7 @@ func TestSettingService_UpdateSettingsRejectsInvalidOpenAIOAuthSchedulingRateMul svc := NewSettingService(repo, &config.Config{}) for _, rate := range []float64{-0.01, math.NaN(), math.Inf(1)} { - err := svc.UpdateSettings(context.Background(), &SystemSettings{OpenAIOAuthSchedulingRateMultiplier: rate}) + err := svc.UpdateSettings(context.Background(), &SystemSettings{OpenAIOAuthSchedulingRateMultiplier: &rate}) require.Error(t, err) } } @@ -515,8 +515,12 @@ func TestSettingService_UpdateSettings_OpenAIAdvancedSchedulerWeightSums(t *test func TestSettingService_ParseSettingsDefaultsOpenAIOAuthSchedulingRateMultiplier(t *testing.T) { svc := NewSettingService(&settingUpdateRepoStub{}, &config.Config{}) - require.Equal(t, 1.0, svc.parseSettings(map[string]string{}).OpenAIOAuthSchedulingRateMultiplier) - require.Equal(t, 0.05, svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05"}).OpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, testPtrFloat64(1.0), svc.parseSettings(map[string]string{}).OpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, testPtrFloat64(0.05), svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0.05"}).OpenAIOAuthSchedulingRateMultiplier) + require.Equal(t, testPtrFloat64(0), svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: "0"}).OpenAIOAuthSchedulingRateMultiplier) + for _, raw := range []string{"", " ", "invalid", "-1", "NaN", "+Inf"} { + require.Nil(t, svc.parseSettings(map[string]string{SettingKeyOpenAIOAuthSchedulingRateMultiplier: raw}).OpenAIOAuthSchedulingRateMultiplier, raw) + } } func TestSettingService_GetAllSettings_OpenAIAdvancedSchedulerEffectiveValuesUseConfig(t *testing.T) { diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 77bac7b211..873d40849a 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -499,7 +499,10 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) updates[SettingKeyOpenAILowUpstreamRatePriorityEnabled] = strconv.FormatBool(settings.OpenAILowUpstreamRatePriorityEnabled) - updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = strconv.FormatFloat(settings.OpenAIOAuthSchedulingRateMultiplier, 'f', -1, 64) + updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = "" + if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate != nil { + updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = strconv.FormatFloat(*rate, 'f', -1, 64) + } updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 83c1422568..a305d390cf 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -268,7 +268,7 @@ type SystemSettings struct { // OpenAI 账号调度 OpenAILowUpstreamRatePriorityEnabled bool - OpenAIOAuthSchedulingRateMultiplier float64 + OpenAIOAuthSchedulingRateMultiplier *float64 OpenAIAdvancedSchedulerEnabled bool OpenAIAdvancedSchedulerStickyWeightedEnabled bool OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4c8c06a7a3..4f0e66b086 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -683,7 +683,8 @@ export interface SystemSettings { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_low_upstream_rate_priority_enabled?: boolean; - openai_oauth_scheduling_rate_multiplier?: number; + /** null means OAuth accounts use their individual account rates. */ + openai_oauth_scheduling_rate_multiplier?: number | null; openai_advanced_scheduler_enabled?: boolean; openai_advanced_scheduler_sticky_weighted_enabled?: boolean; openai_advanced_scheduler_subscription_priority_enabled?: boolean; @@ -1000,7 +1001,8 @@ export interface UpdateSettingsRequest { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_low_upstream_rate_priority_enabled?: boolean; - openai_oauth_scheduling_rate_multiplier?: number; + /** Omit to preserve the override; null clears it; zero is an explicit rate. */ + openai_oauth_scheduling_rate_multiplier?: number | null; openai_advanced_scheduler_enabled?: boolean; openai_advanced_scheduler_sticky_weighted_enabled?: boolean; openai_advanced_scheduler_subscription_priority_enabled?: boolean; diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 99e82fb12b..7037d2e368 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -1219,8 +1219,9 @@ export default { lowRatePriorityTitle: 'Prefer lower rates', lowRatePriorityDescription: 'When enabled, accounts with lower billing rates are preferred. If rates are equal, account priority, current load, and other scheduling factors are considered. This switch is ignored when the experimental scheduler is enabled.', oauthRateTitle: 'OAuth scheduling reference rate', - oauthRatePriorityDescription: 'When a group contains both API Key and OAuth accounts, this rate is used to order OAuth accounts alongside probed API Key billing rates.', - oauthRateWeightedDescription: 'When a group contains both API Key and OAuth accounts, this rate is used for OAuth accounts when calculating the billing-rate score.', + oauthRatePriorityDescription: 'OAuth accounts use this reference rate for low-rate-first ordering. Leave blank to use each account\'s own rate. API Key accounts use a valid probed rate when available, otherwise their account rate.', + oauthRateWeightedDescription: 'OAuth accounts use this reference rate for the billing-rate score. Leave blank to use each account\'s own rate. API Key accounts use a valid probed rate when available, otherwise their account rate.', + oauthRateInvalid: 'The OAuth scheduling reference rate must be a non-negative number, or blank to use account rates.', stickyWeightedTitle: 'Sticky weighting', stickyWeightedDescription: 'When enabled, previous_response_id and session_hash affinity are scored by the advanced scheduler. When disabled, sticky accounts keep the legacy hard-hit behavior.', subscriptionPriorityTitle: 'Subscription priority', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index df178cb67d..ec7614a102 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -1213,8 +1213,9 @@ export default { lowRatePriorityTitle: '低倍率优先', lowRatePriorityDescription: '开启后优先选择计费倍率较低的账号;倍率相同时,再比较账号优先级和当前负载等。启用实验调度策略后,此开关不生效。', oauthRateTitle: 'OAuth 调度参考倍率', - oauthRatePriorityDescription: '同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。', - oauthRateWeightedDescription: '同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。', + oauthRatePriorityDescription: 'OAuth 账号按此参考倍率参与低倍率优先排序;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。', + oauthRateWeightedDescription: '计算“计费倍率”得分时,OAuth 账号使用此参考倍率;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。', + oauthRateInvalid: 'OAuth 调度参考倍率必须是非负数字,或留空以使用账号倍率。', stickyWeightedTitle: '粘性加权', stickyWeightedDescription: '开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。', subscriptionPriorityTitle: '订阅优先', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index c25b06eb3b..15ed5582b9 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5053,7 +5053,6 @@ class="input pr-8" data-testid="openai-oauth-scheduling-rate-multiplier" min="0" - required step="0.01" type="number" /> @@ -5142,7 +5141,6 @@ class="input pr-8" data-testid="openai-oauth-scheduling-rate-multiplier" min="0" - required step="0.01" type="number" /> @@ -9541,6 +9539,7 @@ type SettingsForm = Omit< | "wechat_connect_open_enabled" | "wechat_connect_mp_enabled" | "wechat_connect_mobile_enabled" + | "openai_oauth_scheduling_rate_multiplier" > & { /** Form always binds a concrete boolean (SystemSettings marks this optional). */ channel_monitor_hide_throughput: boolean; @@ -9566,7 +9565,7 @@ type SettingsForm = Omit< google_oauth_client_secret: string; force_email_on_third_party_signup: boolean; openai_low_upstream_rate_priority_enabled: boolean; - openai_oauth_scheduling_rate_multiplier: number; + openai_oauth_scheduling_rate_multiplier: number | "" | null; openai_advanced_scheduler_enabled: boolean; openai_advanced_scheduler_sticky_weighted_enabled: boolean; openai_advanced_scheduler_subscription_priority_enabled: boolean; @@ -10841,6 +10840,10 @@ async function loadSettings() { (form as Record)[key] = value; } } + // For this optional override, null explicitly selects per-account rates. + if (settings.openai_oauth_scheduling_rate_multiplier === null) { + form.openai_oauth_scheduling_rate_multiplier = null; + } syncCaptchaProviderSelection(); if (!form.claude_oauth_system_prompt_blocks?.trim()) { form.claude_oauth_system_prompt_blocks = @@ -11240,6 +11243,16 @@ async function saveSettings() { form.claude_oauth_system_prompt_blocks = claudeOAuthSystemPromptBlocksJSON; + const oauthSchedulingRate = form.openai_oauth_scheduling_rate_multiplier; + if ( + oauthSchedulingRate !== "" && + oauthSchedulingRate !== null && + (!Number.isFinite(oauthSchedulingRate) || oauthSchedulingRate < 0) + ) { + appStore.showError(t("admin.settings.openaiExperimentalScheduler.oauthRateInvalid")); + return; + } + const payload: UpdateSettingsRequest = { registration_enabled: form.registration_enabled, email_verify_enabled: form.email_verify_enabled, @@ -11498,7 +11511,7 @@ async function saveSettings() { openai_low_upstream_rate_priority_enabled: form.openai_low_upstream_rate_priority_enabled, openai_oauth_scheduling_rate_multiplier: - form.openai_oauth_scheduling_rate_multiplier, + oauthSchedulingRate === "" ? null : oauthSchedulingRate, openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled, openai_advanced_scheduler_sticky_weighted_enabled: form.openai_advanced_scheduler_sticky_weighted_enabled, @@ -11607,6 +11620,9 @@ async function saveSettings() { (form as Record)[key] = value; } } + if (updated.openai_oauth_scheduling_rate_multiplier === null) { + form.openai_oauth_scheduling_rate_multiplier = null; + } Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated)); form.default_platform_quotas = normalizePlatformQuotasMap(updated.default_platform_quotas); form.account_scheduling_thresholds = normalizeAccountSchedulingThresholdsMap( diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 8491ff9e30..8103259845 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -201,8 +201,9 @@ vi.mock("vue-i18n", async () => { "admin.settings.openaiExperimentalScheduler.lowRatePriorityTitle": "低倍率优先", "admin.settings.openaiExperimentalScheduler.lowRatePriorityDescription": "开启后优先选择计费倍率较低的账号;倍率相同时,再比较账号优先级和当前负载等。启用实验调度策略后,此开关不生效。", "admin.settings.openaiExperimentalScheduler.oauthRateTitle": "OAuth 调度参考倍率", - "admin.settings.openaiExperimentalScheduler.oauthRatePriorityDescription": "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", - "admin.settings.openaiExperimentalScheduler.oauthRateWeightedDescription": "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。", + "admin.settings.openaiExperimentalScheduler.oauthRatePriorityDescription": "OAuth 账号按此参考倍率参与低倍率优先排序;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。", + "admin.settings.openaiExperimentalScheduler.oauthRateWeightedDescription": "计算“计费倍率”得分时,OAuth 账号使用此参考倍率;留空时使用各自的账号倍率。API Key 账号优先使用有效探测倍率,无有效探测时使用账号倍率。", + "admin.settings.openaiExperimentalScheduler.oauthRateInvalid": "OAuth 调度参考倍率必须是非负数字,或留空以使用账号倍率。", "admin.settings.openaiExperimentalScheduler.stickyWeightedTitle": "粘性加权", "admin.settings.openaiExperimentalScheduler.stickyWeightedDescription": "开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。", "admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle": "订阅优先", @@ -1437,6 +1438,54 @@ describe("admin SettingsView payment visible method controls", () => { }); }); + it.each([false, true])("clears the OAuth rate without losing zero (weighted=%s)", async (weighted) => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + openai_low_upstream_rate_priority_enabled: !weighted, + openai_advanced_scheduler_enabled: weighted, + openai_oauth_scheduling_rate_multiplier: 0.7, + }); + const wrapper = mountView(); + await flushPromises(); + const input = wrapper.get('[data-testid="openai-oauth-scheduling-rate-multiplier"]'); + expect(input.attributes("required")).toBeUndefined(); + await input.setValue(""); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).toHaveBeenLastCalledWith(expect.objectContaining({ + openai_oauth_scheduling_rate_multiplier: null, + })); + await input.setValue("0"); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).toHaveBeenLastCalledWith(expect.objectContaining({ + openai_oauth_scheduling_rate_multiplier: 0, + })); + updateSettings.mockClear(); + await input.setValue("-1"); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledWith("OAuth 调度参考倍率必须是非负数字,或留空以使用账号倍率。"); + }); + + it("loads and preserves an explicitly cleared OAuth rate", async () => { + getSettings.mockResolvedValueOnce({ + ...baseSettingsResponse, + openai_advanced_scheduler_enabled: true, + openai_oauth_scheduling_rate_multiplier: null, + }); + const wrapper = mountView(); + await flushPromises(); + const input = wrapper.get('[data-testid="openai-oauth-scheduling-rate-multiplier"]'); + expect(input.element.value).toBe(""); + await wrapper.find("form").trigger("submit.prevent"); + await flushPromises(); + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ + openai_oauth_scheduling_rate_multiplier: null, + })); + }); + it("places and explains rate controls for both scheduling modes", async () => { const wrapper = mountView(); @@ -1449,7 +1498,7 @@ describe("admin SettingsView payment visible method controls", () => { await lowRateToggle.setValue(true); const priorityModeText = wrapper.text(); expect(priorityModeText).toContain( - "同一分组同时包含 API Key 和 OAuth 账号时,OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", + "OAuth 账号按此参考倍率参与低倍率优先排序;留空时使用各自的账号倍率。", ); expect(priorityModeText.indexOf("低倍率优先")).toBeLessThan( priorityModeText.indexOf("OAuth 调度参考倍率"), @@ -1483,10 +1532,10 @@ describe("admin SettingsView payment visible method controls", () => { ).toBe(true); const weightedModeText = wrapper.text(); expect(weightedModeText).toContain( - "同一分组同时包含 API Key 和 OAuth 账号时,计算“计费倍率”得分时,OAuth 账号按此倍率参与计算。", + "计算“计费倍率”得分时,OAuth 账号使用此参考倍率;留空时使用各自的账号倍率。", ); expect(weightedModeText).not.toContain( - "OAuth 账号按此倍率与已探测的 API Key 计费倍率一起排序。", + "OAuth 账号按此参考倍率参与低倍率优先排序;", ); expect(weightedModeText.indexOf("订阅优先")).toBeLessThan( weightedModeText.indexOf("OAuth 调度参考倍率"),