diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b8b880eab0..99fedb5b1c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -969,6 +969,9 @@ type GatewayOpenAIWSSchedulerScoreWeights struct { Reset float64 `mapstructure:"reset"` // QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。 QuotaHeadroom float64 `mapstructure:"quota_headroom"` + // PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。 + PreviousResponse float64 `mapstructure:"previous_response"` + SessionSticky float64 `mapstructure:"session_sticky"` } // GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。 @@ -1891,6 +1894,8 @@ func setDefaults() { viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5) viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0) viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0) + viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0) + viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0) // OpenAI HTTP upstream protocol strategy viper.SetDefault("gateway.openai_http2.enabled", true) viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true) @@ -2674,7 +2679,9 @@ func (c *Config) Validate() error { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 || c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 || c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 || - c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 { + c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 || + c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 || + c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 { return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative") } weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority + diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8d4a5dfea6..efe95801c3 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -171,15 +171,39 @@ type CheckMixedChannelRequest struct { // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *dto.Account - CurrentConcurrency int `json:"current_concurrency"` + CurrentConcurrency int `json:"current_concurrency"` + SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` + SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` // 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回 CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` // 当前窗口费用 ActiveSessions *int `json:"active_sessions,omitempty"` // 当前活跃会话数 CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +type AccountSchedulerScore struct { + BaseScore float64 `json:"base_score"` + StickyScore float64 `json:"sticky_score"` + StickyScoreInfinity bool `json:"sticky_score_infinity"` + StickyWeightedEnabled bool `json:"sticky_weighted_enabled"` +} + +type AccountSchedulerGroupScore struct { + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name,omitempty"` + GroupPriority *int `json:"group_priority,omitempty"` + AccountSchedulerScore +} + const accountListGroupUngroupedQueryValue = "ungrouped" +type openAIAccountSchedulerScorePoolLister interface { + ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]service.Account, error) +} + +type accountSchedulerScoreFilterPoolLister interface { + ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) +} + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: dto.AccountFromService(account), @@ -226,6 +250,185 @@ func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, ac return item } +func (h *AccountHandler) scoreOpenAIAccountSchedulerPool(ctx context.Context, accounts []service.Account) map[int64]AccountSchedulerScore { + if len(accounts) == 0 { + return nil + } + + openAIAccounts := make([]*service.Account, 0, len(accounts)) + loadReq := make([]service.AccountWithConcurrency, 0, len(accounts)) + for i := range accounts { + account := &accounts[i] + if account.Platform != service.PlatformOpenAI { + continue + } + openAIAccounts = append(openAIAccounts, account) + loadReq = append(loadReq, service.AccountWithConcurrency{ + ID: account.ID, + MaxConcurrency: account.EffectiveLoadFactor(), + }) + } + if len(openAIAccounts) == 0 { + return nil + } + + loadMap := map[int64]*service.AccountLoadInfo{} + if h.concurrencyService != nil { + if batchLoad, err := h.concurrencyService.GetAccountsLoadBatch(ctx, loadReq); err == nil && batchLoad != nil { + loadMap = batchLoad + } + } + + var scores map[int64]service.OpenAIAccountSchedulerScoreSnapshot + if h.rateLimitService != nil { + scores = h.rateLimitService.BuildOpenAIAccountSchedulerScoreSnapshot(ctx, openAIAccounts, loadMap) + } else { + scores = service.BuildOpenAIAccountSchedulerScoreSnapshot(openAIAccounts, loadMap) + } + result := make(map[int64]AccountSchedulerScore, len(scores)) + for accountID, score := range scores { + result[accountID] = AccountSchedulerScore{ + BaseScore: score.BaseScore, + StickyScore: score.StickyScore, + StickyScoreInfinity: score.StickyScoreInfinity, + StickyWeightedEnabled: score.StickyWeightedEnabled, + } + } + return result +} + +func (h *AccountHandler) buildOpenAIAccountSchedulerScores( + ctx context.Context, + accounts []service.Account, + filterPool []service.Account, +) (map[int64]*AccountSchedulerScore, map[int64][]AccountSchedulerGroupScore) { + if len(accounts) == 0 { + return nil, nil + } + if len(filterPool) == 0 { + filterPool = accounts + } + + baseScores := make(map[int64]*AccountSchedulerScore) + for accountID, score := range h.scoreOpenAIAccountSchedulerPool(ctx, filterPool) { + copiedScore := score + baseScores[accountID] = &copiedScore + } + + pageOpenAIAccountIDs := make(map[int64]struct{}) + groupIDs := make(map[int64]struct{}) + for i := range accounts { + account := &accounts[i] + if account.Platform != service.PlatformOpenAI { + continue + } + pageOpenAIAccountIDs[account.ID] = struct{}{} + if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 { + continue + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID > 0 { + groupIDs[accountGroup.GroupID] = struct{}{} + } + } + for _, groupID := range account.GroupIDs { + if groupID > 0 { + groupIDs[groupID] = struct{}{} + } + } + } + if len(pageOpenAIAccountIDs) == 0 { + return baseScores, nil + } + + groupScoresByAccount := make(map[int64][]AccountSchedulerGroupScore) + scoreGroupPool := func(groupID *int64, groupNameByID map[int64]string, groupPriorityByAccount map[int64]int, pool []service.Account) { + if len(pool) == 0 { + return + } + scores := h.scoreOpenAIAccountSchedulerPool(ctx, pool) + for accountID, schedulerScore := range scores { + if _, ok := pageOpenAIAccountIDs[accountID]; !ok { + continue + } + groupScore := AccountSchedulerGroupScore{ + GroupID: groupID, + AccountSchedulerScore: schedulerScore, + } + if groupID != nil { + groupScore.GroupName = groupNameByID[*groupID] + if priority, ok := groupPriorityByAccount[accountID]; ok { + groupScore.GroupPriority = &priority + } + } + groupScoresByAccount[accountID] = append(groupScoresByAccount[accountID], groupScore) + } + } + + if lister, ok := h.adminService.(openAIAccountSchedulerScorePoolLister); ok { + groupIDList := make([]int64, 0, len(groupIDs)) + for groupID := range groupIDs { + groupIDList = append(groupIDList, groupID) + } + sort.Slice(groupIDList, func(i, j int) bool { return groupIDList[i] < groupIDList[j] }) + + for _, groupID := range groupIDList { + gid := groupID + pool, err := lister.ListOpenAISchedulableAccountsForSchedulerScore(ctx, &gid) + if err != nil { + slog.Warn("openai_scheduler_group_score_pool_failed", "group_id", gid, "error", err) + continue + } + groupNameByID := make(map[int64]string) + groupPriorityByAccount := make(map[int64]int) + for i := range pool { + account := &pool[i] + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID != gid { + continue + } + groupPriorityByAccount[account.ID] = accountGroup.Priority + if accountGroup.Group != nil { + groupNameByID[gid] = accountGroup.Group.Name + } + } + } + scoreGroupPool(&gid, groupNameByID, groupPriorityByAccount, pool) + } + + } + + for accountID := range groupScoresByAccount { + sort.SliceStable(groupScoresByAccount[accountID], func(i, j int) bool { + left := groupScoresByAccount[accountID][i] + right := groupScoresByAccount[accountID][j] + return *left.GroupID < *right.GroupID + }) + } + return baseScores, groupScoresByAccount +} + +func (h *AccountHandler) listAccountSchedulerScoreFilterPool( + ctx context.Context, + platform, accountType, status, search string, + groupID int64, + privacyMode string, +) []service.Account { + if h.adminService == nil || (platform != "" && platform != service.PlatformOpenAI) { + return nil + } + lister, ok := h.adminService.(accountSchedulerScoreFilterPoolLister) + if !ok { + return nil + } + accounts, err := lister.ListAccountsForSchedulerScoreFilter(ctx, platform, accountType, status, search, groupID, privacyMode) + if err != nil { + slog.Warn("openai_scheduler_filter_score_pool_failed", "error", err) + return nil + } + return accounts +} + // List handles listing all accounts with pagination // GET /api/v1/admin/accounts func (h *AccountHandler) List(c *gin.Context) { @@ -278,6 +481,8 @@ func (h *AccountHandler) List(c *gin.Context) { var windowCosts map[int64]float64 var activeSessions map[int64]int var rpmCounts map[int64]int + schedulerFilterPool := h.listAccountSchedulerScoreFilterPool(c.Request.Context(), platform, accountType, status, search, groupID, privacyMode) + schedulerScores, schedulerGroupScores := h.buildOpenAIAccountSchedulerScores(c.Request.Context(), accounts, schedulerFilterPool) // 始终获取并发数(Redis ZCARD,极低开销) if h.concurrencyService != nil { @@ -358,6 +563,8 @@ func (h *AccountHandler) List(c *gin.Context) { item := AccountWithConcurrency{ Account: dto.AccountFromService(acc), CurrentConcurrency: concurrencyCounts[acc.ID], + SchedulerScore: schedulerScores[acc.ID], + SchedulerScores: schedulerGroupScores[acc.ID], } // 添加窗口费用(仅当启用时) diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 4d628365df..29e36ad865 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -50,3 +51,222 @@ func TestAccountHandlerListIncludesCreatedAt(t *testing.T) { _, offset := parsed.Zone() require.Equal(t, 0, offset) } + +func TestAccountHandlerListReturnsSchedulerScoresPerGroup(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(41) + adminSvc.accounts = []service.Account{ + { + ID: 101, + Name: "account-high-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + AccountGroups: []service.AccountGroup{ + {AccountID: 101, GroupID: groupID, Priority: 100, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: 102, + Name: "account-low-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 100000, + AccountGroups: []service.AccountGroup{ + {AccountID: 102, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + }, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload struct { + Data struct { + Items []struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name"` + GroupPriority *int `json:"group_priority"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 2) + + var high, low *struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + GroupName string `json:"group_name"` + GroupPriority *int `json:"group_priority"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } + for i := range payload.Data.Items { + item := &payload.Data.Items[i] + switch item.ID { + case 101: + high = item + case 102: + low = item + } + } + require.NotNil(t, high) + require.NotNil(t, low) + require.Len(t, high.SchedulerScores, 1) + require.Len(t, low.SchedulerScores, 1) + require.Equal(t, groupID, *high.SchedulerScores[0].GroupID) + require.Equal(t, "openai", high.SchedulerScores[0].GroupName) + require.Equal(t, 100, *high.SchedulerScores[0].GroupPriority) + require.Equal(t, 1, *low.SchedulerScores[0].GroupPriority) + require.Greater(t, high.SchedulerScores[0].BaseScore, low.SchedulerScores[0].BaseScore) +} + +func TestAccountHandlerListKeepsSchedulerScoreScopedToFilter(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(42) + visibleAccount := service.Account{ + ID: 201, + Name: "visible-low-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 100000, + AccountGroups: []service.AccountGroup{ + {AccountID: 201, GroupID: groupID, Priority: 1, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + } + hiddenGroupPeer := service.Account{ + ID: 202, + Name: "hidden-high-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + AccountGroups: []service.AccountGroup{ + {AccountID: 202, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "openai"}}, + }, + GroupIDs: []int64{groupID}, + CreatedAt: now, + UpdatedAt: now, + } + adminSvc.accounts = []service.Account{visibleAccount} + adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenGroupPeer} + adminSvc.openAISchedulerScorePoolAccounts = []service.Account{visibleAccount, hiddenGroupPeer} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload struct { + Data struct { + Items []struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + item := payload.Data.Items[0] + require.Equal(t, int64(201), item.ID) + require.Len(t, item.SchedulerScores, 1) + require.Equal(t, groupID, *item.SchedulerScores[0].GroupID) + require.Equal(t, item.SchedulerScores[0].BaseScore, item.SchedulerScore.BaseScore) +} + +func TestAccountHandlerListSchedulerScoreIgnoresPagination(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + visibleAccount := service.Account{ + ID: 301, + Name: "visible-low-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 100000, + CreatedAt: now, + UpdatedAt: now, + } + hiddenFilterPeer := service.Account{ + ID: 302, + Name: "hidden-high-priority", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 10, + Priority: 1, + CreatedAt: now, + UpdatedAt: now, + } + adminSvc.accounts = []service.Account{visibleAccount} + adminSvc.accountSchedulerScoreFilterAccounts = []service.Account{visibleAccount, hiddenFilterPeer} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=1&platform=openai", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var payload struct { + Data struct { + Items []struct { + ID int64 `json:"id"` + SchedulerScore struct { + BaseScore float64 `json:"base_score"` + } `json:"scheduler_score"` + SchedulerScores []struct { + GroupID *int64 `json:"group_id"` + BaseScore float64 `json:"base_score"` + } `json:"scheduler_scores"` + } `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + require.Equal(t, int64(301), payload.Data.Items[0].ID) + require.Less(t, payload.Data.Items[0].SchedulerScore.BaseScore, 3.75) + require.Empty(t, payload.Data.Items[0].SchedulerScores) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index f7bab13253..187925e33a 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -10,27 +10,29 @@ import ( ) type stubAdminService struct { - users []service.User - apiKeys []service.APIKey - groups []service.Group - accounts []service.Account - proxies []service.Proxy - proxyCounts []service.ProxyWithAccountCount - redeems []service.RedeemCode - boundAuthIdentity *service.AdminBindAuthIdentityInput - boundAuthIdentityFor int64 - createdAccounts []*service.CreateAccountInput - createdProxies []*service.CreateProxyInput - updatedProxyIDs []int64 - updatedProxies []*service.UpdateProxyInput - testedProxyIDs []int64 - getUserErr error - createAccountErr error - createSparkShadowErr error - updateAccountErr error - bulkUpdateAccountErr error - checkMixedErr error - lastMixedCheck struct { + users []service.User + apiKeys []service.APIKey + groups []service.Group + accounts []service.Account + accountSchedulerScoreFilterAccounts []service.Account + openAISchedulerScorePoolAccounts []service.Account + proxies []service.Proxy + proxyCounts []service.ProxyWithAccountCount + redeems []service.RedeemCode + boundAuthIdentity *service.AdminBindAuthIdentityInput + boundAuthIdentityFor int64 + createdAccounts []*service.CreateAccountInput + createdProxies []*service.CreateProxyInput + updatedProxyIDs []int64 + updatedProxies []*service.UpdateProxyInput + testedProxyIDs []int64 + getUserErr error + createAccountErr error + createSparkShadowErr error + updateAccountErr error + bulkUpdateAccountErr error + checkMixedErr error + lastMixedCheck struct { accountID int64 platform string groupIDs []int64 @@ -329,7 +331,56 @@ func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, s.lastListAccounts.sortBy = sortBy s.lastListAccounts.sortOrder = sortOrder s.lastListAccounts.calls++ - return s.accounts, int64(len(s.accounts)), nil + accounts := s.accounts + total := len(accounts) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = total + } + start := (page - 1) * pageSize + if start >= total { + return []service.Account{}, int64(total), nil + } + end := start + pageSize + if end > total { + end = total + } + return accounts[start:end], int64(total), nil +} + +func (s *stubAdminService) ListAccountsForSchedulerScoreFilter(_ context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) { + if s.accountSchedulerScoreFilterAccounts != nil { + return s.accountSchedulerScoreFilterAccounts, nil + } + return s.accounts, nil +} + +func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ context.Context, groupID *int64) ([]service.Account, error) { + accounts := s.openAISchedulerScorePoolAccounts + if accounts == nil { + accounts = s.accounts + } + out := make([]service.Account, 0, len(accounts)) + for _, account := range accounts { + if account.Platform != service.PlatformOpenAI || !account.IsSchedulable() { + continue + } + if groupID == nil { + if len(account.AccountGroups) == 0 && len(account.GroupIDs) == 0 { + out = append(out, account) + } + continue + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.GroupID == *groupID { + out = append(out, account) + break + } + } + } + return out, nil } func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0f50ab723b..624fddb1e6 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -119,188 +119,210 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { } payload := dto.SystemSettings{ - RegistrationEnabled: settings.RegistrationEnabled, - EmailVerifyEnabled: settings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: settings.PromoCodeEnabled, - PasswordResetEnabled: settings.PasswordResetEnabled, - FrontendURL: settings.FrontendURL, - InvitationCodeEnabled: settings.InvitationCodeEnabled, - TotpEnabled: settings.TotpEnabled, - TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), - LoginAgreementEnabled: settings.LoginAgreementEnabled, - LoginAgreementMode: settings.LoginAgreementMode, - LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), - SMTPHost: settings.SMTPHost, - SMTPPort: settings.SMTPPort, - SMTPUsername: settings.SMTPUsername, - SMTPPasswordConfigured: settings.SMTPPasswordConfigured, - SMTPFrom: settings.SMTPFrom, - SMTPFromName: settings.SMTPFromName, - SMTPUseTLS: settings.SMTPUseTLS, - TurnstileEnabled: settings.TurnstileEnabled, - TurnstileSiteKey: settings.TurnstileSiteKey, - TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, - APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, - LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, - LinuxDoConnectClientID: settings.LinuxDoConnectClientID, - LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, - LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, - DingTalkConnectEnabled: settings.DingTalkConnectEnabled, - DingTalkConnectClientID: settings.DingTalkConnectClientID, - DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured, - DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL, - DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy, - DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID, - DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration, - DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail, - DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName, - DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept, - DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey, - DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey, - DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey, - DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName, - DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName, - DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName, - WeChatConnectEnabled: settings.WeChatConnectEnabled, - WeChatConnectAppID: settings.WeChatConnectAppID, - WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured, - WeChatConnectOpenAppID: settings.WeChatConnectOpenAppID, - WeChatConnectOpenAppSecretConfigured: settings.WeChatConnectOpenAppSecretConfigured, - WeChatConnectMPAppID: settings.WeChatConnectMPAppID, - WeChatConnectMPAppSecretConfigured: settings.WeChatConnectMPAppSecretConfigured, - WeChatConnectMobileAppID: settings.WeChatConnectMobileAppID, - WeChatConnectMobileAppSecretConfigured: settings.WeChatConnectMobileAppSecretConfigured, - WeChatConnectOpenEnabled: settings.WeChatConnectOpenEnabled, - WeChatConnectMPEnabled: settings.WeChatConnectMPEnabled, - WeChatConnectMobileEnabled: settings.WeChatConnectMobileEnabled, - WeChatConnectMode: settings.WeChatConnectMode, - WeChatConnectScopes: settings.WeChatConnectScopes, - WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL, - WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL, - OIDCConnectEnabled: settings.OIDCConnectEnabled, - OIDCConnectProviderName: settings.OIDCConnectProviderName, - OIDCConnectClientID: settings.OIDCConnectClientID, - OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured, - OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: settings.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL, - OIDCConnectScopes: settings.OIDCConnectScopes, - OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE, - OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken, - OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, - GitHubOAuthEnabled: settings.GitHubOAuthEnabled, - GitHubOAuthClientID: settings.GitHubOAuthClientID, - GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured, - GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL, - GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL, - GoogleOAuthEnabled: settings.GoogleOAuthEnabled, - GoogleOAuthClientID: settings.GoogleOAuthClientID, - GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured, - GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL, - GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL, - SiteName: settings.SiteName, - SiteLogo: settings.SiteLogo, - SiteSubtitle: settings.SiteSubtitle, - APIBaseURL: settings.APIBaseURL, - ContactInfo: settings.ContactInfo, - DocURL: settings.DocURL, - HomeContent: settings.HomeContent, - HideCcsImportButton: settings.HideCcsImportButton, - PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, - TableDefaultPageSize: settings.TableDefaultPageSize, - TablePageSizeOptions: settings.TablePageSizeOptions, - CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems), - CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), - DefaultConcurrency: settings.DefaultConcurrency, - DefaultBalance: settings.DefaultBalance, - RiskControlEnabled: settings.RiskControlEnabled, - CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled, - CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds, - AffiliateRebateRate: settings.AffiliateRebateRate, - AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, - AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, - AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap, - DefaultUserRPMLimit: settings.DefaultUserRPMLimit, - DefaultSubscriptions: defaultSubscriptions, - EnableModelFallback: settings.EnableModelFallback, - FallbackModelAnthropic: settings.FallbackModelAnthropic, - FallbackModelOpenAI: settings.FallbackModelOpenAI, - FallbackModelGemini: settings.FallbackModelGemini, - FallbackModelAntigravity: settings.FallbackModelAntigravity, - EnableIdentityPatch: settings.EnableIdentityPatch, - IdentityPatchPrompt: settings.IdentityPatchPrompt, - OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled, - OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled, - OpsQueryModeDefault: settings.OpsQueryModeDefault, - OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, - MinClaudeCodeVersion: settings.MinClaudeCodeVersion, - MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, - BackendModeEnabled: settings.BackendModeEnabled, - EnableFingerprintUnification: settings.EnableFingerprintUnification, - EnableMetadataPassthrough: settings.EnableMetadataPassthrough, - EnableCCHSigning: settings.EnableCCHSigning, - EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, - ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, - ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, - EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, - RewriteMessageCacheControl: settings.RewriteMessageCacheControl, - EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization, - AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, - OpenAICodexUserAgent: settings.OpenAICodexUserAgent, - MinCodexVersion: settings.MinCodexVersion, - MaxCodexVersion: settings.MaxCodexVersion, - CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, - CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist, - CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients, - CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals, - WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, - PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, - PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, - PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled, - PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled, - OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled, - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, - SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled, - AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, - AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails), - PaymentEnabled: paymentCfg.Enabled, - PaymentMinAmount: paymentCfg.MinAmount, - PaymentMaxAmount: paymentCfg.MaxAmount, - PaymentDailyLimit: paymentCfg.DailyLimit, - PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin, - PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders, - PaymentEnabledTypes: paymentCfg.EnabledTypes, - PaymentBalanceDisabled: paymentCfg.BalanceDisabled, - PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, - PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, - PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, - PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, - PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, - PaymentHelpImageURL: paymentCfg.HelpImageURL, - PaymentHelpText: paymentCfg.HelpText, - PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled, - PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax, - PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, - PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, - PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, - PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode, + RegistrationEnabled: settings.RegistrationEnabled, + EmailVerifyEnabled: settings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: settings.PromoCodeEnabled, + PasswordResetEnabled: settings.PasswordResetEnabled, + FrontendURL: settings.FrontendURL, + InvitationCodeEnabled: settings.InvitationCodeEnabled, + TotpEnabled: settings.TotpEnabled, + TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), + SMTPHost: settings.SMTPHost, + SMTPPort: settings.SMTPPort, + SMTPUsername: settings.SMTPUsername, + SMTPPasswordConfigured: settings.SMTPPasswordConfigured, + SMTPFrom: settings.SMTPFrom, + SMTPFromName: settings.SMTPFromName, + SMTPUseTLS: settings.SMTPUseTLS, + TurnstileEnabled: settings.TurnstileEnabled, + TurnstileSiteKey: settings.TurnstileSiteKey, + TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, + APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, + LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, + LinuxDoConnectClientID: settings.LinuxDoConnectClientID, + LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, + LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: settings.DingTalkConnectEnabled, + DingTalkConnectClientID: settings.DingTalkConnectClientID, + DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured, + DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName, + WeChatConnectEnabled: settings.WeChatConnectEnabled, + WeChatConnectAppID: settings.WeChatConnectAppID, + WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured, + WeChatConnectOpenAppID: settings.WeChatConnectOpenAppID, + WeChatConnectOpenAppSecretConfigured: settings.WeChatConnectOpenAppSecretConfigured, + WeChatConnectMPAppID: settings.WeChatConnectMPAppID, + WeChatConnectMPAppSecretConfigured: settings.WeChatConnectMPAppSecretConfigured, + WeChatConnectMobileAppID: settings.WeChatConnectMobileAppID, + WeChatConnectMobileAppSecretConfigured: settings.WeChatConnectMobileAppSecretConfigured, + WeChatConnectOpenEnabled: settings.WeChatConnectOpenEnabled, + WeChatConnectMPEnabled: settings.WeChatConnectMPEnabled, + WeChatConnectMobileEnabled: settings.WeChatConnectMobileEnabled, + WeChatConnectMode: settings.WeChatConnectMode, + WeChatConnectScopes: settings.WeChatConnectScopes, + WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL, + WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL, + OIDCConnectEnabled: settings.OIDCConnectEnabled, + OIDCConnectProviderName: settings.OIDCConnectProviderName, + OIDCConnectClientID: settings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: settings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: settings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: settings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: settings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: settings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: settings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: settings.OIDCConnectJWKSURL, + OIDCConnectScopes: settings.OIDCConnectScopes, + OIDCConnectRedirectURL: settings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: settings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: settings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: settings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: settings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: settings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: settings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: settings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GitHubOAuthClientID: settings.GitHubOAuthClientID, + GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured, + GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + GoogleOAuthClientID: settings.GoogleOAuthClientID, + GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured, + GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL, + SiteName: settings.SiteName, + SiteLogo: settings.SiteLogo, + SiteSubtitle: settings.SiteSubtitle, + APIBaseURL: settings.APIBaseURL, + ContactInfo: settings.ContactInfo, + DocURL: settings.DocURL, + HomeContent: settings.HomeContent, + HideCcsImportButton: settings.HideCcsImportButton, + PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + TableDefaultPageSize: settings.TableDefaultPageSize, + TablePageSizeOptions: settings.TablePageSizeOptions, + CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + DefaultConcurrency: settings.DefaultConcurrency, + DefaultBalance: settings.DefaultBalance, + RiskControlEnabled: settings.RiskControlEnabled, + CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled, + CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds, + AffiliateRebateRate: settings.AffiliateRebateRate, + AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap, + DefaultUserRPMLimit: settings.DefaultUserRPMLimit, + DefaultSubscriptions: defaultSubscriptions, + EnableModelFallback: settings.EnableModelFallback, + FallbackModelAnthropic: settings.FallbackModelAnthropic, + FallbackModelOpenAI: settings.FallbackModelOpenAI, + FallbackModelGemini: settings.FallbackModelGemini, + FallbackModelAntigravity: settings.FallbackModelAntigravity, + EnableIdentityPatch: settings.EnableIdentityPatch, + IdentityPatchPrompt: settings.IdentityPatchPrompt, + OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled, + OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled, + OpsQueryModeDefault: settings.OpsQueryModeDefault, + OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, + MinClaudeCodeVersion: settings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, + BackendModeEnabled: settings.BackendModeEnabled, + EnableFingerprintUnification: settings.EnableFingerprintUnification, + EnableMetadataPassthrough: settings.EnableMetadataPassthrough, + EnableCCHSigning: settings.EnableCCHSigning, + EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection, + ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt, + ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks, + EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection, + RewriteMessageCacheControl: settings.RewriteMessageCacheControl, + EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization, + AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion, + OpenAICodexUserAgent: settings.OpenAICodexUserAgent, + MinCodexVersion: settings.MinCodexVersion, + MaxCodexVersion: settings.MaxCodexVersion, + CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist, + CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist, + CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients, + CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals, + WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, + PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource, + PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource, + PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled, + PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled, + OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled, + OpenAIAdvancedSchedulerStickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + OpenAIAdvancedSchedulerLBTopK: settings.OpenAIAdvancedSchedulerLBTopK, + OpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, + OpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, + OpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, + OpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, + OpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, + OpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, + OpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + OpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + OpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, + OpenAIAdvancedSchedulerEffectiveLBTopK: settings.OpenAIAdvancedSchedulerEffectiveLBTopK, + OpenAIAdvancedSchedulerEffectiveWeightPriority: settings.OpenAIAdvancedSchedulerEffectiveWeightPriority, + OpenAIAdvancedSchedulerEffectiveWeightLoad: settings.OpenAIAdvancedSchedulerEffectiveWeightLoad, + OpenAIAdvancedSchedulerEffectiveWeightQueue: settings.OpenAIAdvancedSchedulerEffectiveWeightQueue, + OpenAIAdvancedSchedulerEffectiveWeightErrorRate: settings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate, + OpenAIAdvancedSchedulerEffectiveWeightTTFT: settings.OpenAIAdvancedSchedulerEffectiveWeightTTFT, + OpenAIAdvancedSchedulerEffectiveWeightReset: settings.OpenAIAdvancedSchedulerEffectiveWeightReset, + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom, + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: settings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse, + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky, + BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, + BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + SubscriptionExpiryNotifyEnabled: settings.SubscriptionExpiryNotifyEnabled, + AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, + AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(settings.AccountQuotaNotifyEmails), + PaymentEnabled: paymentCfg.Enabled, + PaymentMinAmount: paymentCfg.MinAmount, + PaymentMaxAmount: paymentCfg.MaxAmount, + PaymentDailyLimit: paymentCfg.DailyLimit, + PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin, + PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders, + PaymentEnabledTypes: paymentCfg.EnabledTypes, + PaymentBalanceDisabled: paymentCfg.BalanceDisabled, + PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier, + PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate, + PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy, + PaymentProductNamePrefix: paymentCfg.ProductNamePrefix, + PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, + PaymentHelpImageURL: paymentCfg.HelpImageURL, + PaymentHelpText: paymentCfg.HelpText, + PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled, + PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax, + PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, + PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, + PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, + PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode, ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, @@ -618,7 +640,19 @@ type UpdateSettingsRequest struct { PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"` // OpenAI account scheduling - OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"` + 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"` + OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"` + OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"` // 余额不足提醒 BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` @@ -1792,6 +1826,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.OpenAIAdvancedSchedulerEnabled }(), + OpenAIAdvancedSchedulerStickyWeightedEnabled: func() bool { + if req.OpenAIAdvancedSchedulerStickyWeightedEnabled != nil { + return *req.OpenAIAdvancedSchedulerStickyWeightedEnabled + } + return previousSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled + }(), + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: func() bool { + if req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != nil { + return *req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled + } + return previousSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled + }(), + OpenAIAdvancedSchedulerLBTopK: stringSetting(req.OpenAIAdvancedSchedulerLBTopK, previousSettings.OpenAIAdvancedSchedulerLBTopK), + OpenAIAdvancedSchedulerWeightPriority: stringSetting(req.OpenAIAdvancedSchedulerWeightPriority, previousSettings.OpenAIAdvancedSchedulerWeightPriority), + OpenAIAdvancedSchedulerWeightLoad: stringSetting(req.OpenAIAdvancedSchedulerWeightLoad, previousSettings.OpenAIAdvancedSchedulerWeightLoad), + OpenAIAdvancedSchedulerWeightQueue: stringSetting(req.OpenAIAdvancedSchedulerWeightQueue, previousSettings.OpenAIAdvancedSchedulerWeightQueue), + OpenAIAdvancedSchedulerWeightErrorRate: stringSetting(req.OpenAIAdvancedSchedulerWeightErrorRate, previousSettings.OpenAIAdvancedSchedulerWeightErrorRate), + OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT), + OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset), + OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom), + OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse), + OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky), BalanceLowNotifyEnabled: func() bool { if req.BalanceLowNotifyEnabled != nil { return *req.BalanceLowNotifyEnabled @@ -2014,184 +2070,206 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } payload := dto.SystemSettings{ - RegistrationEnabled: updatedSettings.RegistrationEnabled, - EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: updatedSettings.PromoCodeEnabled, - PasswordResetEnabled: updatedSettings.PasswordResetEnabled, - FrontendURL: updatedSettings.FrontendURL, - InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, - TotpEnabled: updatedSettings.TotpEnabled, - TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), - LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, - LoginAgreementMode: updatedSettings.LoginAgreementMode, - LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments), - SMTPHost: updatedSettings.SMTPHost, - SMTPPort: updatedSettings.SMTPPort, - SMTPUsername: updatedSettings.SMTPUsername, - SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured, - SMTPFrom: updatedSettings.SMTPFrom, - SMTPFromName: updatedSettings.SMTPFromName, - SMTPUseTLS: updatedSettings.SMTPUseTLS, - TurnstileEnabled: updatedSettings.TurnstileEnabled, - TurnstileSiteKey: updatedSettings.TurnstileSiteKey, - TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, - APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, - LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, - LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, - LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, - LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, - DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled, - DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID, - DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured, - DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL, - DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy, - DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID, - DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration, - DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail, - DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName, - DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept, - DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey, - DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey, - DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey, - DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName, - DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName, - DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName, - WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled, - WeChatConnectAppID: updatedSettings.WeChatConnectAppID, - WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured, - WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID, - WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured, - WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID, - WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured, - WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID, - WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured, - WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled, - WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled, - WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled, - WeChatConnectMode: updatedSettings.WeChatConnectMode, - WeChatConnectScopes: updatedSettings.WeChatConnectScopes, - WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL, - WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL, - OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, - OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, - OIDCConnectClientID: updatedSettings.OIDCConnectClientID, - OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured, - OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL, - OIDCConnectScopes: updatedSettings.OIDCConnectScopes, - OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE, - OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken, - OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, - GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled, - GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID, - GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured, - GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL, - GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL, - GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled, - GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID, - GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured, - GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL, - GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL, - SiteName: updatedSettings.SiteName, - SiteLogo: updatedSettings.SiteLogo, - SiteSubtitle: updatedSettings.SiteSubtitle, - APIBaseURL: updatedSettings.APIBaseURL, - ContactInfo: updatedSettings.ContactInfo, - DocURL: updatedSettings.DocURL, - HomeContent: updatedSettings.HomeContent, - HideCcsImportButton: updatedSettings.HideCcsImportButton, - PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, - PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, - TableDefaultPageSize: updatedSettings.TableDefaultPageSize, - TablePageSizeOptions: updatedSettings.TablePageSizeOptions, - CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), - CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints), - DefaultConcurrency: updatedSettings.DefaultConcurrency, - DefaultBalance: updatedSettings.DefaultBalance, - AffiliateRebateRate: updatedSettings.AffiliateRebateRate, - AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours, - AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays, - AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap, - DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, - DefaultSubscriptions: updatedDefaultSubscriptions, - EnableModelFallback: updatedSettings.EnableModelFallback, - FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic, - FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI, - FallbackModelGemini: updatedSettings.FallbackModelGemini, - FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity, - EnableIdentityPatch: updatedSettings.EnableIdentityPatch, - IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt, - OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled, - OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled, - OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, - OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, - MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, - MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, - BackendModeEnabled: updatedSettings.BackendModeEnabled, - EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, - EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, - EnableCCHSigning: updatedSettings.EnableCCHSigning, - EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection, - ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt, - ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks, - EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, - RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, - EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization, - AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, - OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, - MinCodexVersion: updatedSettings.MinCodexVersion, - MaxCodexVersion: updatedSettings.MaxCodexVersion, - CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, - CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist, - CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients, - CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals, - PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, - PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, - PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, - PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled, - OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled, - BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, - BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, - BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL, - SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled, - AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled, - AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails), - PaymentEnabled: updatedPaymentCfg.Enabled, - PaymentMinAmount: updatedPaymentCfg.MinAmount, - PaymentMaxAmount: updatedPaymentCfg.MaxAmount, - PaymentDailyLimit: updatedPaymentCfg.DailyLimit, - PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin, - PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders, - PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, - PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, - PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, - PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, - PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, - PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, - PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, - PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, - PaymentHelpText: updatedPaymentCfg.HelpText, - PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled, - PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax, - PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, - PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, - PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, - PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode, + RegistrationEnabled: updatedSettings.RegistrationEnabled, + EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: updatedSettings.PromoCodeEnabled, + PasswordResetEnabled: updatedSettings.PasswordResetEnabled, + FrontendURL: updatedSettings.FrontendURL, + InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, + TotpEnabled: updatedSettings.TotpEnabled, + TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, + LoginAgreementMode: updatedSettings.LoginAgreementMode, + LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments), + SMTPHost: updatedSettings.SMTPHost, + SMTPPort: updatedSettings.SMTPPort, + SMTPUsername: updatedSettings.SMTPUsername, + SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured, + SMTPFrom: updatedSettings.SMTPFrom, + SMTPFromName: updatedSettings.SMTPFromName, + SMTPUseTLS: updatedSettings.SMTPUseTLS, + TurnstileEnabled: updatedSettings.TurnstileEnabled, + TurnstileSiteKey: updatedSettings.TurnstileSiteKey, + TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, + APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, + LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, + LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, + LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, + LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled, + DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID, + DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured, + DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName, + WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled, + WeChatConnectAppID: updatedSettings.WeChatConnectAppID, + WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured, + WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID, + WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured, + WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID, + WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured, + WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID, + WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured, + WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled, + WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled, + WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled, + WeChatConnectMode: updatedSettings.WeChatConnectMode, + WeChatConnectScopes: updatedSettings.WeChatConnectScopes, + WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL, + WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL, + OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, + OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, + OIDCConnectClientID: updatedSettings.OIDCConnectClientID, + OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured, + OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL, + OIDCConnectScopes: updatedSettings.OIDCConnectScopes, + OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE, + OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken, + OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled, + GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID, + GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured, + GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled, + GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID, + GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured, + GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL, + SiteName: updatedSettings.SiteName, + SiteLogo: updatedSettings.SiteLogo, + SiteSubtitle: updatedSettings.SiteSubtitle, + APIBaseURL: updatedSettings.APIBaseURL, + ContactInfo: updatedSettings.ContactInfo, + DocURL: updatedSettings.DocURL, + HomeContent: updatedSettings.HomeContent, + HideCcsImportButton: updatedSettings.HideCcsImportButton, + PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, + PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, + TableDefaultPageSize: updatedSettings.TableDefaultPageSize, + TablePageSizeOptions: updatedSettings.TablePageSizeOptions, + CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems), + CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints), + DefaultConcurrency: updatedSettings.DefaultConcurrency, + DefaultBalance: updatedSettings.DefaultBalance, + AffiliateRebateRate: updatedSettings.AffiliateRebateRate, + AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap, + DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, + DefaultSubscriptions: updatedDefaultSubscriptions, + EnableModelFallback: updatedSettings.EnableModelFallback, + FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic, + FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI, + FallbackModelGemini: updatedSettings.FallbackModelGemini, + FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity, + EnableIdentityPatch: updatedSettings.EnableIdentityPatch, + IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt, + OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled, + OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled, + OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, + OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, + MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, + BackendModeEnabled: updatedSettings.BackendModeEnabled, + EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, + EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, + EnableCCHSigning: updatedSettings.EnableCCHSigning, + EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection, + ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt, + ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks, + EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection, + RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl, + EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization, + AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion, + OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent, + MinCodexVersion: updatedSettings.MinCodexVersion, + MaxCodexVersion: updatedSettings.MaxCodexVersion, + CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist, + CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist, + CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients, + CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals, + PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource, + PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource, + PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled, + PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled, + OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled, + OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + OpenAIAdvancedSchedulerLBTopK: updatedSettings.OpenAIAdvancedSchedulerLBTopK, + OpenAIAdvancedSchedulerWeightPriority: updatedSettings.OpenAIAdvancedSchedulerWeightPriority, + OpenAIAdvancedSchedulerWeightLoad: updatedSettings.OpenAIAdvancedSchedulerWeightLoad, + OpenAIAdvancedSchedulerWeightQueue: updatedSettings.OpenAIAdvancedSchedulerWeightQueue, + OpenAIAdvancedSchedulerWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerWeightErrorRate, + OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT, + OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset, + OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse, + OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky, + OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK, + OpenAIAdvancedSchedulerEffectiveWeightPriority: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPriority, + OpenAIAdvancedSchedulerEffectiveWeightLoad: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightLoad, + OpenAIAdvancedSchedulerEffectiveWeightQueue: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQueue, + OpenAIAdvancedSchedulerEffectiveWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate, + OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT, + OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset, + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom, + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse, + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky, + BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, + BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL, + SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled, + AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled, + AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails), + PaymentEnabled: updatedPaymentCfg.Enabled, + PaymentMinAmount: updatedPaymentCfg.MinAmount, + PaymentMaxAmount: updatedPaymentCfg.MaxAmount, + PaymentDailyLimit: updatedPaymentCfg.DailyLimit, + PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin, + PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders, + PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes, + PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled, + PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier, + PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate, + PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy, + PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix, + PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, + PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, + PaymentHelpText: updatedPaymentCfg.HelpText, + PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled, + PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax, + PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, + PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, + PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, + PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode, ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, @@ -2677,6 +2755,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled { changed = append(changed, "openai_advanced_scheduler_enabled") } + if before.OpenAIAdvancedSchedulerStickyWeightedEnabled != after.OpenAIAdvancedSchedulerStickyWeightedEnabled { + changed = append(changed, "openai_advanced_scheduler_sticky_weighted_enabled") + } + if before.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != after.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled { + changed = append(changed, "openai_advanced_scheduler_subscription_priority_enabled") + } + if before.OpenAIAdvancedSchedulerLBTopK != after.OpenAIAdvancedSchedulerLBTopK { + changed = append(changed, "openai_advanced_scheduler_lb_top_k") + } + if before.OpenAIAdvancedSchedulerWeightPriority != after.OpenAIAdvancedSchedulerWeightPriority { + changed = append(changed, "openai_advanced_scheduler_weight_priority") + } + if before.OpenAIAdvancedSchedulerWeightLoad != after.OpenAIAdvancedSchedulerWeightLoad { + changed = append(changed, "openai_advanced_scheduler_weight_load") + } + if before.OpenAIAdvancedSchedulerWeightQueue != after.OpenAIAdvancedSchedulerWeightQueue { + changed = append(changed, "openai_advanced_scheduler_weight_queue") + } + if before.OpenAIAdvancedSchedulerWeightErrorRate != after.OpenAIAdvancedSchedulerWeightErrorRate { + changed = append(changed, "openai_advanced_scheduler_weight_error_rate") + } + if before.OpenAIAdvancedSchedulerWeightTTFT != after.OpenAIAdvancedSchedulerWeightTTFT { + changed = append(changed, "openai_advanced_scheduler_weight_ttft") + } + if before.OpenAIAdvancedSchedulerWeightReset != after.OpenAIAdvancedSchedulerWeightReset { + changed = append(changed, "openai_advanced_scheduler_weight_reset") + } + if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom { + changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom") + } + if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse { + changed = append(changed, "openai_advanced_scheduler_weight_previous_response") + } + if before.OpenAIAdvancedSchedulerWeightSessionSticky != after.OpenAIAdvancedSchedulerWeightSessionSticky { + changed = append(changed, "openai_advanced_scheduler_weight_session_sticky") + } // 余额、订阅到期与账号限额通知 if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled { changed = append(changed, "balance_low_notify_enabled") @@ -3829,3 +3943,10 @@ func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatfor } return true } + +func stringSetting(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go index f953f76760..1626007f19 100644 --- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go +++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go @@ -217,12 +217,13 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ - "promo_code_enabled": true, - "payment_visible_method_alipay_source": "easypay", - "payment_visible_method_wxpay_source": "wxpay", - "payment_visible_method_alipay_enabled": true, - "payment_visible_method_wxpay_enabled": false, - "openai_advanced_scheduler_enabled": true, + "promo_code_enabled": true, + "payment_visible_method_alipay_source": "easypay", + "payment_visible_method_wxpay_source": "wxpay", + "payment_visible_method_alipay_enabled": true, + "payment_visible_method_wxpay_enabled": false, + "openai_advanced_scheduler_enabled": true, + "openai_advanced_scheduler_subscription_priority_enabled": true, } rawBody, err := json.Marshal(body) require.NoError(t, err) @@ -240,6 +241,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled]) require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled]) require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"]) + require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]) var resp response.Response require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) @@ -250,6 +252,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS require.Equal(t, true, data["payment_visible_method_alipay_enabled"]) require.Equal(t, false, data["payment_visible_method_wxpay_enabled"]) require.Equal(t, true, data["openai_advanced_scheduler_enabled"]) + require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"]) } func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodSource(t *testing.T) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 1d6f73bb99..9a20191a0c 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -208,7 +208,29 @@ type SystemSettings struct { PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"` // OpenAI account scheduling - OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"` + 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"` + 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"` + 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/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 8eec667fa6..ccafde7b02 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -1266,6 +1266,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "previous_response_id must be a response.id (resp_*), not a message id") return } + firstMessageToolContext := service.ValidateFunctionCallOutputContextBytes(firstMessage) + previousResponseCanMove := !firstMessageToolContext.HasFunctionCallOutput || firstMessageToolContext.HasToolCallContext reqLog = reqLog.With( zap.Bool("ws_ingress", true), zap.String("model", reqModel), @@ -1382,6 +1384,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { service.OpenAIEndpointCapabilityChatCompletions, false, requestPlatform, + func() string { + if previousResponseCanMove { + return "previous_response_can_move" + } + return "" + }(), ) if err != nil { reqLog.Warn("openai.websocket_account_select_failed", @@ -1581,8 +1589,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 // 工具续链无法重建,保持原样。仅作用于首轮首包,后续 turn 的续链由 WS 转发层既有逻辑处理。 - if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && - !service.ValidateFunctionCallOutputContextBytes(wsFirstMessage).HasFunctionCallOutput { + if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && previousResponseCanMove { wsFirstMessage = service.RemovePreviousResponseIDFromBody(wsFirstMessage) reqLog.Debug("openai.websocket_previous_response_id_stripped_cross_group", zap.Int64("account_id", account.ID), diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1a21b5893c..a8015ddba0 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -482,7 +482,7 @@ func (r *accountRepository) List(ctx context.Context, params pagination.Paginati return r.ListWithFilters(ctx, params, "", "", "", "", 0, "") } -func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, *pagination.PaginationResult, error) { +func (r *accountRepository) accountListFilteredQuery(platform, accountType, status, search string, groupID int64, privacyMode string) *dbent.AccountQuery { q := r.client.Account.Query() if platform != "" { @@ -575,6 +575,11 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati })) } + return q +} + +func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, *pagination.PaginationResult, error) { + q := r.accountListFilteredQuery(platform, accountType, status, search, groupID, privacyMode) // Clone before Count so interceptor-appended predicates (SoftDeleteMixin's // deleted_at IS NULL) don't accumulate on the shared builder and pollute the // subsequent list query. Same pattern used in group_repo/promo_code_repo/user_repo @@ -603,6 +608,14 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati return outAccounts, paginationResultFromTotal(int64(total), params), nil } +func (r *accountRepository) ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]service.Account, error) { + accounts, err := r.accountListFilteredQuery(platform, accountType, status, search, groupID, privacyMode).All(ctx) + if err != nil { + return nil, err + } + return r.accountsToService(ctx, accounts) +} + func (r *accountRepository) ListOpsAccountsForStats(ctx context.Context, platformFilter string, groupIDFilter *int64) ([]service.Account, error) { if r == nil || r.client == nil { return []service.Account{}, nil diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index c508f09c71..c8e1fe14e0 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -518,7 +518,7 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any { if len(credentials) == 0 { return nil } - keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type"} + keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type"} filtered := make(map[string]any) for _, key := range keys { if value, ok := credentials[key]; ok && value != nil { diff --git a/backend/internal/repository/scheduler_cache_test.go b/backend/internal/repository/scheduler_cache_test.go new file mode 100644 index 0000000000..f438b2f456 --- /dev/null +++ b/backend/internal/repository/scheduler_cache_test.go @@ -0,0 +1,37 @@ +package repository + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestFilterSchedulerCredentialsKeepsSubscriptionPlanType(t *testing.T) { + filtered := filterSchedulerCredentials(map[string]any{ + "plan_type": "plus", + "access_token": "secret-access-token", + "refresh_token": "secret-refresh-token", + }) + + require.Equal(t, "plus", filtered["plan_type"]) + require.NotContains(t, filtered, "access_token") + require.NotContains(t, filtered, "refresh_token") +} + +func TestSchedulerMetadataAccountKeepsOpenAISubscriptionIdentity(t *testing.T) { + account := service.Account{ + ID: 24, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "plan_type": "plus", + "access_token": "secret-access-token", + }, + } + + metadata := buildSchedulerMetadataAccount(account) + + require.True(t, metadata.IsOpenAIChatGPTSubscription()) + require.Empty(t, metadata.GetCredential("access_token")) +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 26f976c87b..dfa480dd18 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -661,15 +661,17 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyTableDefaultPageSize: "20", service.SettingKeyTablePageSizeOptions: "[10,20,50,100]", - service.SettingKeyOpsMonitoringEnabled: "false", - service.SettingKeyOpsRealtimeMonitoringEnabled: "true", - service.SettingKeyOpsQueryModeDefault: "auto", - service.SettingKeyOpsMetricsIntervalSeconds: "60", - service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay, - service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat, - service.SettingPaymentVisibleMethodAlipayEnabled: "true", - service.SettingPaymentVisibleMethodWxpayEnabled: "false", - "openai_advanced_scheduler_enabled": "true", + service.SettingKeyOpsMonitoringEnabled: "false", + service.SettingKeyOpsRealtimeMonitoringEnabled: "true", + service.SettingKeyOpsQueryModeDefault: "auto", + service.SettingKeyOpsMetricsIntervalSeconds: "60", + service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay, + service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat, + service.SettingPaymentVisibleMethodAlipayEnabled: "true", + service.SettingPaymentVisibleMethodWxpayEnabled: "false", + "openai_advanced_scheduler_enabled": "true", + service.SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", + service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", }) }, method: http.MethodGet, @@ -861,6 +863,28 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_alipay_enabled": true, "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": true, + "openai_advanced_scheduler_sticky_weighted_enabled": false, + "openai_advanced_scheduler_subscription_priority_enabled": false, + "openai_advanced_scheduler_lb_top_k": "", + "openai_advanced_scheduler_weight_priority": "", + "openai_advanced_scheduler_weight_load": "", + "openai_advanced_scheduler_weight_queue": "", + "openai_advanced_scheduler_weight_error_rate": "", + "openai_advanced_scheduler_weight_ttft": "", + "openai_advanced_scheduler_weight_reset": "", + "openai_advanced_scheduler_weight_quota_headroom": "", + "openai_advanced_scheduler_weight_previous_response": "", + "openai_advanced_scheduler_weight_session_sticky": "", + "openai_advanced_scheduler_effective_lb_top_k": "7", + "openai_advanced_scheduler_effective_weight_priority": "1", + "openai_advanced_scheduler_effective_weight_load": "1", + "openai_advanced_scheduler_effective_weight_queue": "0.7", + "openai_advanced_scheduler_effective_weight_error_rate": "0.8", + "openai_advanced_scheduler_effective_weight_ttft": "0.5", + "openai_advanced_scheduler_effective_weight_reset": "0", + "openai_advanced_scheduler_effective_weight_quota_headroom": "0", + "openai_advanced_scheduler_effective_weight_previous_response": "5", + "openai_advanced_scheduler_effective_weight_session_sticky": "3", "openai_codex_user_agent": "", "openai_fast_policy_settings": { "rules": [] @@ -1110,6 +1134,28 @@ func TestAPIContracts(t *testing.T) { "payment_visible_method_alipay_enabled": false, "payment_visible_method_wxpay_enabled": false, "openai_advanced_scheduler_enabled": false, + "openai_advanced_scheduler_sticky_weighted_enabled": false, + "openai_advanced_scheduler_subscription_priority_enabled": false, + "openai_advanced_scheduler_lb_top_k": "", + "openai_advanced_scheduler_weight_priority": "", + "openai_advanced_scheduler_weight_load": "", + "openai_advanced_scheduler_weight_queue": "", + "openai_advanced_scheduler_weight_error_rate": "", + "openai_advanced_scheduler_weight_ttft": "", + "openai_advanced_scheduler_weight_reset": "", + "openai_advanced_scheduler_weight_quota_headroom": "", + "openai_advanced_scheduler_weight_previous_response": "", + "openai_advanced_scheduler_weight_session_sticky": "", + "openai_advanced_scheduler_effective_lb_top_k": "7", + "openai_advanced_scheduler_effective_weight_priority": "1", + "openai_advanced_scheduler_effective_weight_load": "1", + "openai_advanced_scheduler_effective_weight_queue": "0.7", + "openai_advanced_scheduler_effective_weight_error_rate": "0.8", + "openai_advanced_scheduler_effective_weight_ttft": "0.5", + "openai_advanced_scheduler_effective_weight_reset": "0", + "openai_advanced_scheduler_effective_weight_quota_headroom": "0", + "openai_advanced_scheduler_effective_weight_previous_response": "5", + "openai_advanced_scheduler_effective_weight_session_sticky": "3", "openai_codex_user_agent": "", "openai_fast_policy_settings": { "rules": [] diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 26af0a7281..8db5805e9d 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1182,6 +1182,18 @@ func (a *Account) IsOpenAIOAuth() bool { return a.IsOpenAI() && a.Type == AccountTypeOAuth } +func (a *Account) IsOpenAIChatGPTSubscription() bool { + if !a.IsOpenAIOAuth() { + return false + } + switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) { + case "", "free", "abnormal": + return false + default: + return true + } +} + func (a *Account) IsOpenAIPersonalAccessToken() bool { if !a.IsOpenAIOAuth() { return false diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 7685174f3b..ce59c34475 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2618,6 +2618,29 @@ func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, return accounts, result.Total, nil } +func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) { + if s == nil || s.accountRepo == nil { + return nil, nil + } + lister, ok := s.accountRepo.(interface { + ListAllWithFilters(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) + }) + if !ok { + return nil, nil + } + return lister.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode) +} + +func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) { + if s == nil || s.accountRepo == nil { + return nil, nil + } + if groupID != nil { + return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) + } + return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI) +} + func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) { return s.accountRepo.GetByID(ctx, id) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index ab853c280a..bc1db19105 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -431,6 +431,20 @@ const ( // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403) SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling" + // SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled OpenAI 高级调度下是否启用粘性加权。 + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled = "openai_advanced_scheduler_sticky_weighted_enabled" + // SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled OpenAI 高级调度下是否优先使用订阅账号池。 + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled = "openai_advanced_scheduler_subscription_priority_enabled" + SettingKeyOpenAIAdvancedSchedulerLBTopK = "openai_advanced_scheduler_lb_top_k" + SettingKeyOpenAIAdvancedSchedulerWeightPriority = "openai_advanced_scheduler_weight_priority" + SettingKeyOpenAIAdvancedSchedulerWeightLoad = "openai_advanced_scheduler_weight_load" + SettingKeyOpenAIAdvancedSchedulerWeightQueue = "openai_advanced_scheduler_weight_queue" + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate = "openai_advanced_scheduler_weight_error_rate" + SettingKeyOpenAIAdvancedSchedulerWeightTTFT = "openai_advanced_scheduler_weight_ttft" + SettingKeyOpenAIAdvancedSchedulerWeightReset = "openai_advanced_scheduler_weight_reset" + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom = "openai_advanced_scheduler_weight_quota_headroom" + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse = "openai_advanced_scheduler_weight_previous_response" + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky = "openai_advanced_scheduler_weight_session_sticky" // SettingKeyBackendModeEnabled Backend 模式:禁用用户注册和自助服务,仅管理员可登录 SettingKeyBackendModeEnabled = "backend_mode_enabled" diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index ab298d4521..dd65163abc 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -36,8 +36,20 @@ const ( ) type cachedOpenAIAdvancedSchedulerSetting struct { - enabled bool - expiresAt int64 + enabled bool + stickyWeightedEnabled bool + subscriptionPriorityEnabled bool + lbTopKOverride int + weightOverrides map[string]float64 + expiresAt int64 +} + +type openAIAdvancedSchedulerRuntimeSettings struct { + enabled bool + stickyWeightedEnabled bool + subscriptionPriorityEnabled bool + lbTopKOverride int + weightOverrides map[string]float64 } var openAIAdvancedSchedulerSettingCache atomic.Value // *cachedOpenAIAdvancedSchedulerSetting @@ -48,8 +60,12 @@ type OpenAIAccountScheduleRequest struct { Platform string SessionHash string StickyAccountID int64 + StickyPreviousAccountID int64 + StickyWeighted bool + SubscriptionPriority bool PreserveStickyBinding bool PreviousResponseID string + PreviousResponseCanMove bool RequestedModel string RequiredTransport OpenAIUpstreamTransport RequiredCapability OpenAIEndpointCapability @@ -111,6 +127,17 @@ type openAIAccountLoadPlan struct { loadSkew float64 } +type openAIAccountLoadSelectionAttempt struct { + result *AccountSelectionResult + selectionOrder []openAIAccountCandidateScore + candidateCount int + topK int + loadSkew float64 + compactBlocked bool + noCompactCandidates bool + err error +} + func (m *openAIAccountSchedulerMetrics) recordSelect(decision OpenAIAccountScheduleDecision) { if m == nil { return @@ -277,7 +304,8 @@ func (s *defaultOpenAIAccountScheduler) Select( }() previousResponseID := strings.TrimSpace(req.PreviousResponseID) - if previousResponseID != "" && normalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI { + if previousResponseID != "" && normalizeOpenAICompatiblePlatform(req.Platform) == PlatformOpenAI && + (!req.StickyWeighted || !req.PreviousResponseCanMove) { selection, err := s.service.selectAccountByPreviousResponseIDForCapability( ctx, req.GroupID, @@ -310,19 +338,21 @@ func (s *defaultOpenAIAccountScheduler) Select( } } - selection, escapedSticky, err := s.selectBySessionHash(ctx, req) - if err != nil { - return nil, decision, err - } - if selection != nil && selection.Account != nil { - decision.Layer = openAIAccountScheduleLayerSessionSticky - decision.StickySessionHit = true - decision.SelectedAccountID = selection.Account.ID - decision.SelectedAccountType = selection.Account.Type - return selection, decision, nil - } - if escapedSticky { - req.PreserveStickyBinding = true + if !req.StickyWeighted { + selection, escapedSticky, err := s.selectBySessionHash(ctx, req) + if err != nil { + return nil, decision, err + } + if selection != nil && selection.Account != nil { + decision.Layer = openAIAccountScheduleLayerSessionSticky + decision.StickySessionHit = true + decision.SelectedAccountID = selection.Account.ID + decision.SelectedAccountType = selection.Account.Type + return selection, decision, nil + } + if escapedSticky { + req.PreserveStickyBinding = true + } } selection, candidateCount, topK, loadSkew, err := s.selectByLoadBalance(ctx, req) @@ -336,6 +366,14 @@ func (s *defaultOpenAIAccountScheduler) Select( if selection != nil && selection.Account != nil { decision.SelectedAccountID = selection.Account.ID decision.SelectedAccountType = selection.Account.Type + if req.StickyWeighted { + if req.StickyPreviousAccountID > 0 && selection.Account.ID == req.StickyPreviousAccountID { + decision.StickyPreviousHit = true + } + if req.StickyAccountID > 0 && selection.Account.ID == req.StickyAccountID { + decision.StickySessionHit = true + } + } } return selection, decision, nil } @@ -453,6 +491,13 @@ func openAIStickyAccountMatchesGroup(account *Account, groupID *int64) bool { return false } +func openAIAccountSchedulingPriority(account *Account) int { + if account == nil { + return 0 + } + return account.Priority +} + func (s *defaultOpenAIAccountScheduler) shouldEscapeStickyAccount(accountID int64, cfg openAIStickyEscapeConfig) (reason string, errorRate float64, ttft float64, shouldEscape bool) { if !cfg.enabled || s == nil || s.stats == nil || accountID <= 0 { return "", 0, 0, false @@ -471,6 +516,7 @@ type openAIAccountCandidateScore struct { account *Account loadInfo *AccountLoadInfo score float64 + priority int errorRate float64 ttft float64 hasTTFT bool @@ -669,6 +715,7 @@ func buildOpenAIWeightedSelectionOrder( } func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( + ctx context.Context, req OpenAIAccountScheduleRequest, filtered []*Account, loadMap map[int64]*AccountLoadInfo, @@ -716,18 +763,20 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( return plan } - minPriority, maxPriority := candidates[0].account.Priority, candidates[0].account.Priority + minPriority, maxPriority := openAIAccountSchedulingPriority(candidates[0].account), openAIAccountSchedulingPriority(candidates[0].account) maxWaiting := 1 loadRateSum := 0.0 loadRateSumSquares := 0.0 minTTFT, maxTTFT := 0.0, 0.0 hasTTFTSample := false - for _, candidate := range candidates { - if candidate.account.Priority < minPriority { - minPriority = candidate.account.Priority + for i := range candidates { + candidate := &candidates[i] + candidate.priority = openAIAccountSchedulingPriority(candidate.account) + if candidate.priority < minPriority { + minPriority = candidate.priority } - if candidate.account.Priority > maxPriority { - maxPriority = candidate.account.Priority + if candidate.priority > maxPriority { + maxPriority = candidate.priority } if candidate.loadInfo.WaitingCount > maxWaiting { maxWaiting = candidate.loadInfo.WaitingCount @@ -751,7 +800,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( } plan.loadSkew = calcLoadSkewByMoments(loadRateSum, loadRateSumSquares, len(candidates)) - weights := s.service.openAIWSSchedulerWeights() + weights := s.service.openAIWSSchedulerWeightsForRequest(ctx) // Reset 因子(use-it-or-lose-it):在拥有「未来会话窗口结束时间」的账号中, // 剩余时间越短 → 因子越接近 1(越早重置越优先用尽)。无活跃窗口的账号因子为 0。 @@ -785,7 +834,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( item := &candidates[i] priorityFactor := 1.0 if maxPriority > minPriority { - priorityFactor = 1 - float64(item.account.Priority-minPriority)/float64(maxPriority-minPriority) + priorityFactor = 1 - float64(item.priority-minPriority)/float64(maxPriority-minPriority) } loadFactor := 1 - clamp01(float64(item.loadInfo.LoadRate)/100.0) queueFactor := 1 - clamp01(float64(item.loadInfo.WaitingCount)/float64(maxWaiting)) @@ -817,10 +866,18 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan( weights.TTFT*ttftFactor + weights.Reset*resetFactor + weights.QuotaHeadroom*quotaHeadroomFactor + if req.StickyWeighted { + if req.PreviousResponseCanMove && req.StickyPreviousAccountID > 0 && item.account.ID == req.StickyPreviousAccountID { + item.score += weights.Previous + } + if req.StickyAccountID > 0 && item.account.ID == req.StickyAccountID { + item.score += weights.SessionSticky + } + } } plan.candidates = candidates - plan.topK = s.service.openAIWSLBTopK() + plan.topK = s.service.openAIWSLBTopKForRequest(ctx) if plan.topK > len(candidates) { plan.topK = len(candidates) } @@ -845,6 +902,20 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAISelectionOrder( groupTopK = len(pool) } ranked := selectTopKOpenAICandidates(pool, groupTopK) + if req.StickyWeighted { + for _, stickyID := range []int64{req.StickyPreviousAccountID, req.StickyAccountID} { + if stickyID <= 0 { + continue + } + for i, candidate := range ranked { + if candidate.account != nil && candidate.account.ID == stickyID { + ordered := append([]openAIAccountCandidateScore{candidate}, ranked[:i]...) + ordered = append(ordered, ranked[i+1:]...) + return ordered + } + } + } + } return buildOpenAIWeightedSelectionOrder(ranked, req) } @@ -939,6 +1010,66 @@ func (s *defaultOpenAIAccountScheduler) tryAcquireOpenAISelectionOrder( return nil, compactBlocked, nil } +func (s *defaultOpenAIAccountScheduler) tryFallbackToWeightedSticky( + ctx context.Context, + req OpenAIAccountScheduleRequest, +) (*AccountSelectionResult, error) { + if !req.StickyWeighted { + return nil, nil + } + for _, accountID := range []int64{req.StickyPreviousAccountID, req.StickyAccountID} { + if accountID <= 0 { + continue + } + if req.ExcludedIDs != nil { + if _, excluded := req.ExcludedIDs[accountID]; excluded { + continue + } + } + account, err := s.service.getSchedulableAccount(ctx, accountID) + if err != nil || account == nil { + continue + } + if !s.isAccountRequestCompatible(ctx, account, req) || !s.isAccountTransportCompatible(account, req.RequiredTransport) { + continue + } + account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.Platform, req.RequestedModel, req.RequireCompact, req.RequiredCapability) + if account == nil || !s.isAccountRequestCompatible(ctx, account, req) || !s.isAccountTransportCompatible(account, req.RequiredTransport) { + continue + } + if req.RequireCompact && openAICompactSupportTier(account) == 0 { + continue + } + result, acquireErr := s.service.tryAcquireAccountSlot(ctx, account.ID, account.Concurrency) + if acquireErr != nil { + return nil, acquireErr + } + if result != nil && result.Acquired { + if req.SessionHash != "" && !req.PreserveStickyBinding { + _ = s.service.BindStickySession(ctx, req.GroupID, req.SessionHash, account.ID) + } + return &AccountSelectionResult{ + Account: account, + Acquired: true, + ReleaseFunc: result.ReleaseFunc, + }, nil + } + if s.service.concurrencyService != nil { + cfg := s.service.schedulingConfig() + return &AccountSelectionResult{ + Account: account, + WaitPlan: &AccountWaitPlan{ + AccountID: account.ID, + MaxConcurrency: account.Concurrency, + Timeout: cfg.StickySessionWaitTimeout, + MaxWaiting: cfg.StickySessionMaxWaiting, + }, + }, nil + } + } + return nil, nil +} + func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( ctx context.Context, req OpenAIAccountScheduleRequest, @@ -1002,52 +1133,159 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( } } - plan := s.buildOpenAIAccountLoadPlan(req, filtered, loadMap) - candidateCount := plan.candidateCount - topK := plan.topK - loadSkew := plan.loadSkew - selectionOrder := plan.selectionOrder - if req.RequireCompact && len(plan.candidates) == 0 && len(plan.staleSnapshotCompactRetry) == 0 { - return nil, 0, 0, 0, ErrNoAvailableCompactAccounts - } - if req.RequireCompact && len(selectionOrder) == 0 && s.service.schedulerSnapshot == nil { - return nil, candidateCount, topK, loadSkew, ErrNoAvailableCompactAccounts - } - if len(selectionOrder) == 0 { - return nil, candidateCount, topK, loadSkew, noAvailableOpenAISelectionError(req.RequestedModel, req.RequireCompact && len(plan.allCandidates) > 0) + if req.SubscriptionPriority { + subscriptionAccounts, regularAccounts := partitionOpenAIChatGPTSubscriptionAccounts(filtered) + if len(subscriptionAccounts) > 0 { + attempt := s.trySelectByLoadBalancePool(ctx, req, subscriptionAccounts, loadMap) + if attempt.err != nil && (!attempt.noCompactCandidates || len(regularAccounts) <= 0) { + return nil, attempt.candidateCount, attempt.topK, attempt.loadSkew, attempt.err + } + if attempt.result != nil { + return attempt.result, attempt.candidateCount, attempt.topK, attempt.loadSkew, nil + } + if len(regularAccounts) > 0 { + regularAttempt := s.trySelectByLoadBalancePool(ctx, req, regularAccounts, loadMap) + if regularAttempt.err != nil { + return nil, regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew, regularAttempt.err + } + if regularAttempt.result != nil { + return regularAttempt.result, regularAttempt.candidateCount, regularAttempt.topK, regularAttempt.loadSkew, nil + } + return s.finishLoadBalanceSelectionFallback(ctx, req, regularAttempt) + } + return s.finishLoadBalanceSelectionFallback(ctx, req, attempt) + } } - result, compactBlocked, acquireErr := s.tryAcquireOpenAISelectionOrder(ctx, req, selectionOrder) + attempt := s.trySelectByLoadBalancePool(ctx, req, filtered, loadMap) + if attempt.err != nil { + return nil, attempt.candidateCount, attempt.topK, attempt.loadSkew, attempt.err + } + if attempt.result != nil { + return attempt.result, attempt.candidateCount, attempt.topK, attempt.loadSkew, nil + } + return s.finishLoadBalanceSelectionFallback(ctx, req, attempt) +} + +func partitionOpenAIChatGPTSubscriptionAccounts(accounts []*Account) ([]*Account, []*Account) { + subscriptionAccounts := make([]*Account, 0, len(accounts)) + regularAccounts := make([]*Account, 0, len(accounts)) + for _, account := range accounts { + if account != nil && account.IsOpenAIChatGPTSubscription() { + subscriptionAccounts = append(subscriptionAccounts, account) + continue + } + regularAccounts = append(regularAccounts, account) + } + return subscriptionAccounts, regularAccounts +} + +func (s *defaultOpenAIAccountScheduler) trySelectByLoadBalancePool( + ctx context.Context, + req OpenAIAccountScheduleRequest, + filtered []*Account, + loadMap map[int64]*AccountLoadInfo, +) openAIAccountLoadSelectionAttempt { + plan := s.buildOpenAIAccountLoadPlan(ctx, req, filtered, loadMap) + attempt := openAIAccountLoadSelectionAttempt{ + selectionOrder: plan.selectionOrder, + candidateCount: plan.candidateCount, + topK: plan.topK, + loadSkew: plan.loadSkew, + } + if req.RequireCompact && len(plan.candidates) == 0 && len(plan.staleSnapshotCompactRetry) == 0 { + attempt.noCompactCandidates = true + attempt.err = ErrNoAvailableCompactAccounts + return attempt + } + if req.RequireCompact && len(attempt.selectionOrder) == 0 && s.service.schedulerSnapshot == nil { + attempt.noCompactCandidates = true + attempt.err = ErrNoAvailableCompactAccounts + return attempt + } + if len(attempt.selectionOrder) == 0 { + attempt.compactBlocked = req.RequireCompact && len(plan.allCandidates) > 0 + return attempt + } + + result, compactBlocked, acquireErr := s.tryAcquireOpenAISelectionOrder(ctx, req, attempt.selectionOrder) + attempt.compactBlocked = compactBlocked if acquireErr != nil { - return nil, candidateCount, topK, loadSkew, acquireErr + attempt.err = acquireErr + return attempt } if result != nil { - return result, candidateCount, topK, loadSkew, nil + attempt.result = result + return attempt } if s.service.concurrencyService != nil { + loadReq := buildOpenAIAccountLoadRequest(filtered) if freshLoadMap, loadErr := s.service.concurrencyService.GetAccountsLoadBatchFresh(ctx, loadReq); loadErr == nil { - freshPlan := s.buildOpenAIAccountLoadPlan(req, filtered, freshLoadMap) + freshPlan := s.buildOpenAIAccountLoadPlan(ctx, req, filtered, freshLoadMap) if len(freshPlan.selectionOrder) > 0 { freshResult, freshCompactBlocked, freshAcquireErr := s.tryAcquireOpenAISelectionOrder(ctx, req, freshPlan.selectionOrder) if freshAcquireErr != nil { - return nil, candidateCount, topK, loadSkew, freshAcquireErr + attempt.err = freshAcquireErr + return attempt } if freshResult != nil { - return freshResult, freshPlan.candidateCount, freshPlan.topK, freshPlan.loadSkew, nil + attempt.result = freshResult + attempt.selectionOrder = freshPlan.selectionOrder + attempt.candidateCount = freshPlan.candidateCount + attempt.topK = freshPlan.topK + attempt.loadSkew = freshPlan.loadSkew + return attempt } - compactBlocked = compactBlocked || freshCompactBlocked - selectionOrder = freshPlan.selectionOrder - candidateCount = freshPlan.candidateCount - topK = freshPlan.topK - loadSkew = freshPlan.loadSkew + attempt.compactBlocked = attempt.compactBlocked || freshCompactBlocked + attempt.selectionOrder = freshPlan.selectionOrder + attempt.candidateCount = freshPlan.candidateCount + attempt.topK = freshPlan.topK + attempt.loadSkew = freshPlan.loadSkew } } } + return attempt +} + +func buildOpenAIAccountLoadRequest(accounts []*Account) []AccountWithConcurrency { + loadReq := make([]AccountWithConcurrency, 0, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + loadReq = append(loadReq, AccountWithConcurrency{ + ID: account.ID, + MaxConcurrency: account.EffectiveLoadFactor(), + }) + } + return loadReq +} + +func (s *defaultOpenAIAccountScheduler) finishLoadBalanceSelectionFallback( + ctx context.Context, + req OpenAIAccountScheduleRequest, + attempt openAIAccountLoadSelectionAttempt, +) (*AccountSelectionResult, int, int, float64, error) { + candidateCount := attempt.candidateCount + topK := attempt.topK + loadSkew := attempt.loadSkew + + if len(attempt.selectionOrder) == 0 { + return nil, candidateCount, topK, loadSkew, noAvailableOpenAISelectionError(req.RequestedModel, attempt.compactBlocked) + } + + if stickyFallback, stickyErr := s.tryFallbackToWeightedSticky(ctx, req); stickyErr != nil { + return nil, candidateCount, topK, loadSkew, stickyErr + } else if stickyFallback != nil { + return stickyFallback, candidateCount, topK, loadSkew, nil + } + cfg := s.service.schedulingConfig() + compactBlocked := attempt.compactBlocked // WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。 - for _, candidate := range selectionOrder { + for _, candidate := range attempt.selectionOrder { fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.Platform, req.RequestedModel, false, req.RequiredCapability) if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) { continue @@ -1184,40 +1422,164 @@ func (s *OpenAIGatewayService) openAIAdvancedSchedulerSettingRepo() SettingRepos return s.rateLimitService.settingService.settingRepo } -func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerEnabled(ctx context.Context) bool { +func (s *OpenAIGatewayService) openAIAdvancedSchedulerRuntimeSettings(ctx context.Context) openAIAdvancedSchedulerRuntimeSettings { if cached, ok := openAIAdvancedSchedulerSettingCache.Load().(*cachedOpenAIAdvancedSchedulerSetting); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { - return cached.enabled + return openAIAdvancedSchedulerRuntimeSettings{ + enabled: cached.enabled, + stickyWeightedEnabled: cached.stickyWeightedEnabled, + subscriptionPriorityEnabled: cached.subscriptionPriorityEnabled, + lbTopKOverride: cached.lbTopKOverride, + weightOverrides: cloneOpenAIAdvancedSchedulerWeightOverrides(cached.weightOverrides), + } } } result, _, _ := openAIAdvancedSchedulerSettingSF.Do(openAIAdvancedSchedulerSettingKey, func() (any, error) { if cached, ok := openAIAdvancedSchedulerSettingCache.Load().(*cachedOpenAIAdvancedSchedulerSetting); ok && cached != nil { if time.Now().UnixNano() < cached.expiresAt { - return cached.enabled, nil + return openAIAdvancedSchedulerRuntimeSettings{ + enabled: cached.enabled, + stickyWeightedEnabled: cached.stickyWeightedEnabled, + subscriptionPriorityEnabled: cached.subscriptionPriorityEnabled, + lbTopKOverride: cached.lbTopKOverride, + weightOverrides: cloneOpenAIAdvancedSchedulerWeightOverrides(cached.weightOverrides), + }, nil } } enabled := false + stickyWeightedEnabled := false + subscriptionPriorityEnabled := false + lbTopKOverride := 0 + weightOverrides := map[string]float64{} if repo := s.openAIAdvancedSchedulerSettingRepo(); repo != nil { dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAdvancedSchedulerSettingDBTimeout) defer cancel() - value, err := repo.GetValue(dbCtx, openAIAdvancedSchedulerSettingKey) - if err == nil { - enabled = strings.EqualFold(strings.TrimSpace(value), "true") + if values, err := repo.GetMultiple(dbCtx, openAIAdvancedSchedulerRuntimeSettingKeys()); err == nil { + enabled = strings.EqualFold(strings.TrimSpace(values[openAIAdvancedSchedulerSettingKey]), "true") + stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]), "true") + subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(values[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]), "true") + lbTopKOverride = parsePositiveIntOverride(values[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + weightOverrides = parseOpenAIAdvancedSchedulerWeightOverrides(values) + } else { + if value, err := repo.GetValue(dbCtx, openAIAdvancedSchedulerSettingKey); err == nil { + enabled = strings.EqualFold(strings.TrimSpace(value), "true") + } + if value, err := repo.GetValue(dbCtx, SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled); err == nil { + stickyWeightedEnabled = strings.EqualFold(strings.TrimSpace(value), "true") + } + if value, err := repo.GetValue(dbCtx, SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled); err == nil { + subscriptionPriorityEnabled = strings.EqualFold(strings.TrimSpace(value), "true") + } } } openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ - enabled: enabled, - expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), + enabled: enabled, + stickyWeightedEnabled: stickyWeightedEnabled, + subscriptionPriorityEnabled: subscriptionPriorityEnabled, + lbTopKOverride: lbTopKOverride, + weightOverrides: cloneOpenAIAdvancedSchedulerWeightOverrides(weightOverrides), + expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), }) - return enabled, nil + return openAIAdvancedSchedulerRuntimeSettings{ + enabled: enabled, + stickyWeightedEnabled: stickyWeightedEnabled, + subscriptionPriorityEnabled: subscriptionPriorityEnabled, + lbTopKOverride: lbTopKOverride, + weightOverrides: weightOverrides, + }, nil }) - enabled, _ := result.(bool) - return enabled + settings, _ := result.(openAIAdvancedSchedulerRuntimeSettings) + return settings +} + +func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerEnabled(ctx context.Context) bool { + return s.openAIAdvancedSchedulerRuntimeSettings(ctx).enabled +} + +func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx context.Context) bool { + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + return settings.enabled && settings.stickyWeightedEnabled +} + +func (s *OpenAIGatewayService) isOpenAIAdvancedSchedulerSubscriptionPriorityEnabled(ctx context.Context) bool { + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + return settings.enabled && settings.subscriptionPriorityEnabled +} + +func openAIAdvancedSchedulerRuntimeSettingKeys() []string { + keys := []string{ + openAIAdvancedSchedulerSettingKey, + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled, + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + SettingKeyOpenAIAdvancedSchedulerLBTopK, + } + for _, spec := range openAIAdvancedSchedulerWeightOverrideSpecs() { + keys = append(keys, spec.key) + } + return keys +} + +type openAIAdvancedSchedulerWeightOverrideSpec struct { + key string + name string +} + +func openAIAdvancedSchedulerWeightOverrideSpecs() []openAIAdvancedSchedulerWeightOverrideSpec { + return []openAIAdvancedSchedulerWeightOverrideSpec{ + {key: SettingKeyOpenAIAdvancedSchedulerWeightPriority, name: "priority"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightLoad, name: "load"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightQueue, name: "queue"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightErrorRate, name: "error_rate"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightTTFT, name: "ttft"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightReset, name: "reset"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom, name: "quota_headroom"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse, name: "previous_response"}, + {key: SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky, name: "session_sticky"}, + } +} + +func parsePositiveIntOverride(raw string) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0 + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0 + } + return value +} + +func parseOpenAIAdvancedSchedulerWeightOverrides(values map[string]string) map[string]float64 { + overrides := map[string]float64{} + for _, spec := range openAIAdvancedSchedulerWeightOverrideSpecs() { + raw := strings.TrimSpace(values[spec.key]) + if raw == "" { + continue + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + continue + } + overrides[spec.name] = value + } + return overrides +} + +func cloneOpenAIAdvancedSchedulerWeightOverrides(in map[string]float64) map[string]float64 { + if len(in) == 0 { + return nil + } + out := make(map[string]float64, len(in)) + for key, value := range in { + out[key] = value + } + return out } func (s *OpenAIGatewayService) getOpenAIAccountScheduler(ctx context.Context) OpenAIAccountScheduler { @@ -1253,7 +1615,7 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler( requiredTransport OpenAIUpstreamTransport, requireCompact bool, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { - return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", "", requireCompact, PlatformOpenAI) + return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", "", requireCompact, PlatformOpenAI, false) } func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability( @@ -1269,10 +1631,14 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability( platformOverride ...string, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { platform := PlatformOpenAI + previousResponseCanMove := false if len(platformOverride) > 0 { platform = platformOverride[0] } - return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, "", requireCompact, platform) + if len(platformOverride) > 1 { + previousResponseCanMove = strings.EqualFold(platformOverride[1], "previous_response_can_move") + } + return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, "", requireCompact, platform, previousResponseCanMove) } func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages( @@ -1283,13 +1649,13 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages( excludedIDs map[int64]struct{}, requiredCapability OpenAIImagesCapability, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { - selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", requiredCapability, false, PlatformOpenAI) + selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", requiredCapability, false, PlatformOpenAI, false) if err == nil && selection != nil && selection.Account != nil { return selection, decision, nil } // 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号) if requiredCapability == OpenAIImagesCapabilityNative { - return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", OpenAIImagesCapabilityBasic, false, PlatformOpenAI) + return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", OpenAIImagesCapabilityBasic, false, PlatformOpenAI, false) } return selection, decision, err } @@ -1306,6 +1672,7 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( requiredImageCapability OpenAIImagesCapability, requireCompact bool, platform string, + previousResponseCanMove bool, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) platform = normalizeOpenAICompatiblePlatform(platform) @@ -1378,13 +1745,23 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( stickyAccountID = accountID } } + stickyWeighted := s.isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx) + subscriptionPriority := s.isOpenAIAdvancedSchedulerSubscriptionPriorityEnabled(ctx) + stickyPreviousAccountID := int64(0) + if stickyWeighted && previousResponseCanMove && strings.TrimSpace(previousResponseID) != "" && platform == PlatformOpenAI { + stickyPreviousAccountID = s.ResolveAccountIDByPreviousResponseIDForScheduler(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + } return scheduler.Select(ctx, OpenAIAccountScheduleRequest{ GroupID: groupID, Platform: platform, SessionHash: sessionHash, StickyAccountID: stickyAccountID, + StickyPreviousAccountID: stickyPreviousAccountID, + StickyWeighted: stickyWeighted, + SubscriptionPriority: subscriptionPriority, PreviousResponseID: previousResponseID, + PreviousResponseCanMove: previousResponseCanMove, RequestedModel: requestedModel, RequiredTransport: requiredTransport, RequiredCapability: requiredCapability, @@ -1473,6 +1850,15 @@ func (s *OpenAIGatewayService) openAIWSLBTopK() int { return 7 } +func (s *OpenAIGatewayService) openAIWSLBTopKForRequest(ctx context.Context) int { + base := s.openAIWSLBTopK() + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + if settings.lbTopKOverride > 0 { + return settings.lbTopKOverride + } + return base +} + func (s *OpenAIGatewayService) openAIStickyEscapeConfig() openAIStickyEscapeConfig { if s != nil && s.cfg != nil { cfg := s.cfg.Gateway.OpenAIScheduler @@ -1514,6 +1900,8 @@ func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedul TTFT: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT, Reset: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset, QuotaHeadroom: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom, + Previous: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse, + SessionSticky: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky, } } return GatewayOpenAIWSSchedulerScoreWeightsView{ @@ -1524,9 +1912,46 @@ func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedul TTFT: 0.5, Reset: 0.0, QuotaHeadroom: 0.0, + Previous: 5.0, + SessionSticky: 3.0, } } +func (s *OpenAIGatewayService) openAIWSSchedulerWeightsForRequest(ctx context.Context) GatewayOpenAIWSSchedulerScoreWeightsView { + weights := s.openAIWSSchedulerWeights() + settings := s.openAIAdvancedSchedulerRuntimeSettings(ctx) + return applyOpenAIAdvancedSchedulerWeightOverrides(weights, settings.weightOverrides) +} + +func applyOpenAIAdvancedSchedulerWeightOverrides( + weights GatewayOpenAIWSSchedulerScoreWeightsView, + overrides map[string]float64, +) GatewayOpenAIWSSchedulerScoreWeightsView { + for key, value := range overrides { + switch key { + case "priority": + weights.Priority = value + case "load": + weights.Load = value + case "queue": + weights.Queue = value + case "error_rate": + weights.ErrorRate = value + case "ttft": + weights.TTFT = value + case "reset": + weights.Reset = value + case "quota_headroom": + weights.QuotaHeadroom = value + case "previous_response": + weights.Previous = value + case "session_sticky": + weights.SessionSticky = value + } + } + return weights +} + type GatewayOpenAIWSSchedulerScoreWeightsView struct { Priority float64 Load float64 @@ -1536,6 +1961,149 @@ type GatewayOpenAIWSSchedulerScoreWeightsView struct { // Reset 倾向「会话窗口最早重置」的账号;0 表示关闭(默认)。 Reset float64 QuotaHeadroom float64 + Previous float64 + SessionSticky float64 +} + +type OpenAIAccountSchedulerScoreSnapshot struct { + BaseScore float64 + StickyScore float64 + StickyScoreInfinity bool + StickyWeightedEnabled bool +} + +func (s *RateLimitService) BuildOpenAIAccountSchedulerScoreSnapshot( + ctx context.Context, + accounts []*Account, + loadMap map[int64]*AccountLoadInfo, +) map[int64]OpenAIAccountSchedulerScoreSnapshot { + gateway := &OpenAIGatewayService{cfg: nil, rateLimitService: s} + if s != nil { + gateway.cfg = s.cfg + } + return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeightsForRequest(ctx), gateway.isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx)) +} + +func BuildOpenAIAccountSchedulerScoreSnapshot( + accounts []*Account, + loadMap map[int64]*AccountLoadInfo, +) map[int64]OpenAIAccountSchedulerScoreSnapshot { + gateway := &OpenAIGatewayService{} + return buildOpenAIAccountSchedulerScoreSnapshot(accounts, loadMap, gateway.openAIWSSchedulerWeights(), false) +} + +func buildOpenAIAccountSchedulerScoreSnapshot( + accounts []*Account, + loadMap map[int64]*AccountLoadInfo, + weights GatewayOpenAIWSSchedulerScoreWeightsView, + stickyWeightedEnabled bool, +) map[int64]OpenAIAccountSchedulerScoreSnapshot { + if len(accounts) == 0 { + return nil + } + candidates := make([]openAIAccountCandidateScore, 0, len(accounts)) + for _, account := range accounts { + if account == nil { + continue + } + loadInfo := loadMap[account.ID] + if loadInfo == nil { + loadInfo = &AccountLoadInfo{AccountID: account.ID} + } + candidates = append(candidates, openAIAccountCandidateScore{ + account: account, + loadInfo: loadInfo, + errorRate: 0, + ttft: 0, + hasTTFT: false, + }) + } + if len(candidates) == 0 { + return nil + } + + minPriority, maxPriority := openAIAccountSchedulingPriority(candidates[0].account), openAIAccountSchedulingPriority(candidates[0].account) + maxWaiting := 1 + for i := range candidates { + candidate := &candidates[i] + candidate.priority = openAIAccountSchedulingPriority(candidate.account) + if candidate.priority < minPriority { + minPriority = candidate.priority + } + if candidate.priority > maxPriority { + maxPriority = candidate.priority + } + if candidate.loadInfo.WaitingCount > maxWaiting { + maxWaiting = candidate.loadInfo.WaitingCount + } + } + + minResetRemaining, maxResetRemaining := 0.0, 0.0 + hasResetSample := false + now := time.Now() + if weights.Reset > 0 { + for _, candidate := range candidates { + end := candidate.account.SessionWindowEnd + if end == nil || !now.Before(*end) { + continue + } + remaining := end.Sub(now).Seconds() + if !hasResetSample { + minResetRemaining, maxResetRemaining = remaining, remaining + hasResetSample = true + continue + } + if remaining < minResetRemaining { + minResetRemaining = remaining + } + if remaining > maxResetRemaining { + maxResetRemaining = remaining + } + } + } + + result := make(map[int64]OpenAIAccountSchedulerScoreSnapshot, len(candidates)) + for _, candidate := range candidates { + priorityFactor := 1.0 + if maxPriority > minPriority { + priorityFactor = 1 - float64(candidate.priority-minPriority)/float64(maxPriority-minPriority) + } + loadFactor := 1 - clamp01(float64(candidate.loadInfo.LoadRate)/100.0) + queueFactor := 1 - clamp01(float64(candidate.loadInfo.WaitingCount)/float64(maxWaiting)) + errorFactor := 1.0 + ttftFactor := 0.5 + resetFactor := 0.0 + if weights.Reset > 0 && hasResetSample { + if end := candidate.account.SessionWindowEnd; end != nil && now.Before(*end) { + if maxResetRemaining > minResetRemaining { + resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining)) + } else { + resetFactor = 1 + } + } + } + quotaHeadroomFactor := 0.0 + if weights.QuotaHeadroom > 0 { + quotaHeadroomFactor = openAIQuotaHeadroomFactor(candidate.account, now) + } + baseScore := weights.Priority*priorityFactor + + weights.Load*loadFactor + + weights.Queue*queueFactor + + weights.ErrorRate*errorFactor + + weights.TTFT*ttftFactor + + weights.Reset*resetFactor + + weights.QuotaHeadroom*quotaHeadroomFactor + score := OpenAIAccountSchedulerScoreSnapshot{ + BaseScore: baseScore, + StickyWeightedEnabled: stickyWeightedEnabled, + StickyScoreInfinity: !stickyWeightedEnabled, + } + if stickyWeightedEnabled { + score.StickyScore = baseScore + weights.Previous + weights.SessionSticky + } + result[candidate.account.ID] = score + } + return result } func openAIQuotaHeadroomFactor(account *Account, now time.Time) float64 { diff --git a/backend/internal/service/openai_account_scheduler_reset_test.go b/backend/internal/service/openai_account_scheduler_reset_test.go index 50155f20d6..f1270c8caa 100644 --- a/backend/internal/service/openai_account_scheduler_reset_test.go +++ b/backend/internal/service/openai_account_scheduler_reset_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "testing" "time" @@ -49,7 +50,7 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightPrefersSoonestReset(t *testing.T) } sched := openAIResetTestScheduler(5.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Greater(t, scores[2], scores[1], "重置时间最早的账号(ID=2)得分更高") } @@ -65,7 +66,7 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightZeroNoEffect(t *testing.T) { } sched := openAIResetTestScheduler(0.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Equal(t, scores[1], scores[2], "Reset 权重为 0 时两账号得分相同") } @@ -80,7 +81,7 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightIgnoresNilWindow(t *testing.T) { } sched := openAIResetTestScheduler(5.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Greater(t, scores[2], scores[1], "拥有活跃窗口的账号得分高于无窗口账号") } @@ -161,7 +162,7 @@ func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomPrefersHigher7dRemaining(t *tes } sched := openAIQuotaHeadroomTestScheduler(1.0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Greater(t, scores[2], scores[1], "7d 剩余额度更高的账号得分应更高") } @@ -190,7 +191,7 @@ func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomZeroNoEffect(t *testing.T) { } sched := openAIResetTestScheduler(0) - plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) + plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{}) scores := openAIPlanScores(plan) require.Equal(t, scores[1], scores[2], "quota_headroom 权重为 0 时不应影响打分") } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 0255dbbde3..a61a923053 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -183,6 +183,17 @@ func newSchedulerTestOpenAIWSV2Config() *config.Config { return cfg } +func newSchedulerTestSubscriptionPriorityConfig() *config.Config { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0 + return cfg +} + type openAIAdvancedSchedulerSettingRepoStub struct { values map[string]string } @@ -210,8 +221,14 @@ func (s *openAIAdvancedSchedulerSettingRepoStub) Set(context.Context, string, st panic("unexpected call to Set") } -func (s *openAIAdvancedSchedulerSettingRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) { - panic("unexpected call to GetMultiple") +func (s *openAIAdvancedSchedulerSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { + result := make(map[string]string, len(keys)) + for _, key := range keys { + if value, err := s.GetValue(context.Background(), key); err == nil { + result[key] = value + } + } + return result, nil } func (s *openAIAdvancedSchedulerSettingRepoStub) SetMultiple(context.Context, map[string]string) error { @@ -226,7 +243,7 @@ func (s *openAIAdvancedSchedulerSettingRepoStub) Delete(context.Context, string) panic("unexpected call to Delete") } -func newOpenAIAdvancedSchedulerRateLimitService(enabled string) *RateLimitService { +func newOpenAIAdvancedSchedulerRateLimitService(enabled string, values ...string) *RateLimitService { resetOpenAIAdvancedSchedulerSettingCacheForTest() repo := &openAIAdvancedSchedulerSettingRepoStub{ values: map[string]string{}, @@ -234,6 +251,12 @@ func newOpenAIAdvancedSchedulerRateLimitService(enabled string) *RateLimitServic if enabled != "" { repo.values[openAIAdvancedSchedulerSettingKey] = enabled } + if len(values) > 0 && values[0] != "" { + repo.values[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = values[0] + } + if len(values) > 1 && values[1] != "" { + repo.values[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = values[1] + } return &RateLimitService{ settingService: NewSettingService(repo, &config.Config{}), } @@ -266,6 +289,45 @@ func (s *openAISnapshotCacheStub) GetAccount(ctx context.Context, accountID int6 return &cloned, nil } +func TestOpenAIGatewayService_OpenAIAdvancedSchedulerRuntimeSettings_DBOverridesConfig(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 11 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 1, + Load: 2, + Queue: 3, + ErrorRate: 4, + TTFT: 5, + Reset: 6, + QuotaHeadroom: 7, + PreviousResponse: 8, + SessionSticky: 9, + } + repo := &openAIAdvancedSchedulerSettingRepoStub{ + values: map[string]string{ + openAIAdvancedSchedulerSettingKey: "true", + SettingKeyOpenAIAdvancedSchedulerLBTopK: "3", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "2.5", + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "12", + }, + } + svc := &OpenAIGatewayService{ + cfg: cfg, + rateLimitService: &RateLimitService{settingService: NewSettingService(repo, cfg)}, + } + + ctx := context.Background() + require.Equal(t, 3, svc.openAIWSLBTopKForRequest(ctx)) + weights := svc.openAIWSSchedulerWeightsForRequest(ctx) + require.Equal(t, 2.5, weights.Priority) + require.Equal(t, 2.0, weights.Load) + require.Equal(t, 12.0, weights.Previous) + require.Equal(t, 9.0, weights.SessionSticky) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabledUsesLegacyLoadAwareness(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -584,6 +646,247 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPrev require.True(t, decision.StickyPreviousHit) } +func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedSessionInTopKUsesStickyFirst(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(101071) + accounts := []Account{ + { + ID: 37101, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 100, + GroupIDs: []int64{groupID}, + }, + { + ID: 37102, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + }, + } + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 2 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky = 3 + cache := &schedulerTestGatewayCache{sessionBindings: map[string]int64{ + "openai:session_hash_weighted_topk": 37101, + }} + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: cache, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &groupID, + "", + "session_hash_weighted_topk", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + false, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37101), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.True(t, decision.StickySessionHit) + require.Equal(t, 2, decision.TopK) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_StickyWeightedPreviousRequiresMovableContext(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(101072) + accounts := []Account{ + { + ID: 37111, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 100, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + }, + }, + { + ID: 37112, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + }, + }, + } + cfg := newSchedulerTestOpenAIWSV2Config() + cfg.Gateway.OpenAIWS.LBTopK = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5 + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + store := svc.getOpenAIWSStateStore() + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_weighted_unmovable", 37111, time.Hour)) + + selection, decision, err := svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "resp_weighted_unmovable", + "", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityChatCompletions, + false, + PlatformOpenAI, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37111), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerPreviousResponse, decision.Layer) + require.True(t, decision.StickyPreviousHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + + selection, decision, err = svc.SelectAccountWithSchedulerForCapability( + ctx, + &groupID, + "resp_weighted_unmovable", + "", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + OpenAIEndpointCapabilityChatCompletions, + false, + PlatformOpenAI, + "previous_response_can_move", + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37112), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_PreviousResponseCompactUnsupportedDeletesBinding(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(101073) + accounts := []Account{ + { + ID: 37121, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + "openai_compact_mode": OpenAICompactModeForceOff, + }, + }, + { + ID: 37122, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + GroupIDs: []int64{groupID}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_enabled": true, + "openai_compact_mode": OpenAICompactModeForceOn, + }, + }, + } + cfg := newSchedulerTestOpenAIWSV2Config() + cfg.Gateway.OpenAIWS.LBTopK = 2 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 1 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0.7 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0.8 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0.5 + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + store := svc.getOpenAIWSStateStore() + require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_compact_unsupported", 37121, time.Hour)) + + selection, decision, err := svc.SelectAccountWithScheduler( + ctx, + &groupID, + "resp_compact_unsupported", + "", + "gpt-5.1", + nil, + OpenAIUpstreamTransportAny, + true, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(37122), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.False(t, decision.StickyPreviousHit) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } + + accountID, err := store.GetResponseAccount(ctx, groupID, "resp_compact_unsupported") + require.NoError(t, err) + require.Zero(t, accountID) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_Enabled_EmbeddingsSkipsChatOnlyAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -1560,6 +1863,217 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyEscapeDisa require.True(t, decision.StickySessionHit) } +func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityChoosesSubscriptionPoolFirst(t *testing.T) { + ctx := context.Background() + groupID := int64(10120) + accounts := []Account{ + { + ID: 21601, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"plan_type": "plus"}, + }, + { + ID: 21602, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + }, + } + concurrencyCache := schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{21601: true, 21602: true}, + loadMap: map[int64]*AccountLoadInfo{ + 21601: {AccountID: 21601, LoadRate: 90, WaitingCount: 1}, + 21602: {AccountID: 21602, LoadRate: 0, WaitingCount: 0}, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: newSchedulerTestSubscriptionPriorityConfig(), + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "true"), + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_subscription_first", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21601), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.Equal(t, 1, decision.TopK) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityFallsBackWhenSubscriptionFull(t *testing.T) { + ctx := context.Background() + groupID := int64(10121) + accounts := []Account{ + { + ID: 21611, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"plan_type": "team"}, + }, + { + ID: 21612, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 9, + GroupIDs: []int64{groupID}, + }, + } + concurrencyCache := schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{21611: false, 21612: true}, + loadMap: map[int64]*AccountLoadInfo{ + 21611: {AccountID: 21611, LoadRate: 0, WaitingCount: 0}, + 21612: {AccountID: 21612, LoadRate: 90, WaitingCount: 1}, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: newSchedulerTestSubscriptionPriorityConfig(), + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "true"), + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_subscription_fallback", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21612), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + require.True(t, selection.Acquired) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_SubscriptionPriorityDisabledUsesScore(t *testing.T) { + ctx := context.Background() + groupID := int64(10122) + accounts := []Account{ + { + ID: 21621, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 10, + GroupIDs: []int64{groupID}, + Credentials: map[string]any{"plan_type": "pro"}, + }, + { + ID: 21622, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + GroupIDs: []int64{groupID}, + }, + } + concurrencyCache := schedulerTestConcurrencyCache{ + acquireResults: map[int64]bool{21621: true, 21622: true}, + loadMap: map[int64]*AccountLoadInfo{ + 21621: {AccountID: 21621, LoadRate: 90, WaitingCount: 1}, + 21622: {AccountID: 21622, LoadRate: 0, WaitingCount: 0}, + }, + } + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: newSchedulerTestSubscriptionPriorityConfig(), + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true", "", "false"), + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_subscription_disabled", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21622), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + +func TestOpenAIGatewayService_SelectAccountWithScheduler_UsesAccountPriorityWithinGroupPool(t *testing.T) { + ctx := context.Background() + groupID := int64(10123) + accounts := []Account{ + { + ID: 21631, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 1, + AccountGroups: []AccountGroup{ + {AccountID: 21631, GroupID: groupID, Priority: 100}, + }, + GroupIDs: []int64{groupID}, + }, + { + ID: 21632, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 100000, + AccountGroups: []AccountGroup{ + {AccountID: 21632, GroupID: groupID, Priority: 1}, + }, + GroupIDs: []int64{groupID}, + }, + } + cfg := newSchedulerTestSubscriptionPriorityConfig() + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0 + svc := &OpenAIGatewayService{ + accountRepo: schedulerGroupAwareOpenAIAccountRepo{schedulerTestOpenAIAccountRepo{accounts: accounts}}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"), + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, decision, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "session_group_priority", "gpt-5.1", nil, OpenAIUpstreamTransportAny, false) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(21631), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) + if selection.ReleaseFunc != nil { + selection.ReleaseFunc() + } +} + func TestDefaultOpenAIAccountScheduler_ShouldEscapeStickyAccount_ThresholdBoundary(t *testing.T) { stats := newOpenAIAccountRuntimeStats() accountID := int64(21501) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 9261db5544..fcc4b98064 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -4325,87 +4325,8 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( if s == nil { return nil, nil } - responseID := strings.TrimSpace(previousResponseID) - if responseID == "" { - return nil, nil - } - store := s.getOpenAIWSStateStore() - if store == nil { - return nil, nil - } - - accountID, err := store.GetResponseAccount(ctx, derefGroupID(groupID), responseID) - if err != nil || accountID <= 0 { - return nil, nil - } - if excludedIDs != nil { - if _, excluded := excludedIDs[accountID]; excluded { - return nil, nil - } - } - - account, err := s.getSchedulableAccount(ctx, accountID) - if err != nil || account == nil { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, - // 以保持“回滚到 HTTP”后的历史行为一致性。 - if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - return nil, nil - } - if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if requestedModel != "" && !account.IsModelSupported(requestedModel) { - return nil, nil - } - if !account.SupportsOpenAIEndpointCapability(requiredCapability) { - return nil, nil - } - // Quota auto-pause must also gate the previous_response_id sticky path; otherwise an - // account over its 5h/7d threshold keeps serving the same response chain even though - // normal scheduling skips it. Pause is transient, so fall through to normal scheduling - // without deleting the binding (the window may reset before the next turn). - if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { - return nil, nil - } - if s.schedulerSnapshot != nil && s.accountRepo != nil { - latest, latestErr := s.accountRepo.GetByID(ctx, account.ID) - if latestErr != nil || latest == nil { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - if requestedModel != "" && !latest.IsModelSupported(requestedModel) { - return nil, nil - } - if !latest.SupportsOpenAIEndpointCapability(requiredCapability) { - return nil, nil - } - if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { - return nil, nil - } - if s.isOpenAIAccountRuntimeBlocked(latest) { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) - return nil, nil - } - account = latest - } - if requireCompact && openAICompactSupportTier(account) == 0 { - _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + accountID, account, responseID, store := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + if accountID <= 0 || account == nil || store == nil { return nil, nil } @@ -4439,6 +4360,117 @@ func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability( return nil, nil } +func (s *OpenAIGatewayService) ResolveAccountIDByPreviousResponseIDForScheduler( + ctx context.Context, + groupID *int64, + previousResponseID string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredCapability OpenAIEndpointCapability, + requireCompact bool, +) int64 { + accountID, _, _, _ := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact) + return accountID +} + +func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( + ctx context.Context, + groupID *int64, + previousResponseID string, + requestedModel string, + excludedIDs map[int64]struct{}, + requiredCapability OpenAIEndpointCapability, + requireCompact bool, +) (int64, *Account, string, OpenAIWSStateStore) { + if s == nil { + return 0, nil, "", nil + } + responseID := strings.TrimSpace(previousResponseID) + if responseID == "" { + return 0, nil, "", nil + } + store := s.getOpenAIWSStateStore() + if store == nil { + return 0, nil, "", nil + } + + accountID, err := store.GetResponseAccount(ctx, derefGroupID(groupID), responseID) + if err != nil || accountID <= 0 { + return 0, nil, "", nil + } + if excludedIDs != nil { + if _, excluded := excludedIDs[accountID]; excluded { + return 0, nil, "", nil + } + } + + account, err := s.getSchedulableAccount(ctx, accountID) + if err != nil || account == nil { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + // 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连, + // 以保持“回滚到 HTTP”后的历史行为一致性。 + if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { + return 0, nil, "", nil + } + if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if requestedModel != "" && !account.IsModelSupported(requestedModel) { + return 0, nil, "", nil + } + if !account.SupportsOpenAIEndpointCapability(requiredCapability) { + return 0, nil, "", nil + } + // Quota auto-pause must also gate the previous_response_id sticky path; otherwise an + // account over its 5h/7d threshold keeps serving the same response chain even though + // normal scheduling skips it. Pause is transient, so fall through to normal scheduling + // without deleting the binding (the window may reset before the next turn). + if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { + return 0, nil, "", nil + } + if s.schedulerSnapshot != nil && s.accountRepo != nil { + latest, latestErr := s.accountRepo.GetByID(ctx, account.ID) + if latestErr != nil || latest == nil { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + if requestedModel != "" && !latest.IsModelSupported(requestedModel) { + return 0, nil, "", nil + } + if !latest.SupportsOpenAIEndpointCapability(requiredCapability) { + return 0, nil, "", nil + } + if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused { + return 0, nil, "", nil + } + if s.isOpenAIAccountRuntimeBlocked(latest) { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + account = latest + } + if requireCompact && openAICompactSupportTier(account) == 0 { + _ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID) + return 0, nil, "", nil + } + return accountID, account, responseID, store +} + func classifyOpenAIWSAcquireError(err error) string { if err == nil { return "acquire_conn" diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 81a915be67..50d38e7d13 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -116,6 +116,14 @@ func (s *RateLimitService) SetAccountRuntimeBlocker(blocker AccountRuntimeBlocke s.runtimeBlocker = blocker } +func (s *RateLimitService) IsOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx context.Context) bool { + if s == nil || s.settingService == nil { + return false + } + gateway := &OpenAIGatewayService{rateLimitService: s} + return gateway.isOpenAIAdvancedSchedulerStickyWeightedEnabled(ctx) +} + func (s *RateLimitService) notifyAccountSchedulingBlocked(account *Account, until time.Time, reason string) { if s == nil || s.runtimeBlocker == nil || account == nil { return diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 1a5e677a12..1024243bea 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1921,6 +1921,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if err != nil { return nil, err } + if err := normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil { + return nil, err + } settings.PaymentVisibleMethodAlipaySource = alipaySource settings.PaymentVisibleMethodWxpaySource = wxpaySource settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID) @@ -2229,6 +2232,18 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled) updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled) updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled) + updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK + updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority + updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad + updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue + updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate + updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT + updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset + updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom + updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse + updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky // 余额、订阅到期与账号限额通知 updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) @@ -2376,7 +2391,21 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { }) openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey) openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{ - enabled: settings.OpenAIAdvancedSchedulerEnabled, + enabled: settings.OpenAIAdvancedSchedulerEnabled, + stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled, + subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled, + lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK), + weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{ + SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority, + SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad, + SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue, + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate, + SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT, + SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset, + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky, + }), expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), }) // Invalidate the quota auto-pause cache and let the next read trigger a fresh load. @@ -3196,17 +3225,29 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(), // 分组隔离(默认不允许未分组 Key 调度) - SettingKeyAllowUngroupedKeyScheduling: "false", - SettingKeyEnableAnthropicCacheTTL1hInjection: "false", - SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), - SettingKeyEnableClientDatelineNormalization: "true", - SettingKeyAntigravityUserAgentVersion: "", - SettingKeyOpenAICodexUserAgent: "", - SettingPaymentVisibleMethodAlipaySource: "", - SettingPaymentVisibleMethodWxpaySource: "", - SettingPaymentVisibleMethodAlipayEnabled: "false", - SettingPaymentVisibleMethodWxpayEnabled: "false", - openAIAdvancedSchedulerSettingKey: "false", + SettingKeyAllowUngroupedKeyScheduling: "false", + SettingKeyEnableAnthropicCacheTTL1hInjection: "false", + SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()), + SettingKeyEnableClientDatelineNormalization: "true", + SettingKeyAntigravityUserAgentVersion: "", + SettingKeyOpenAICodexUserAgent: "", + SettingPaymentVisibleMethodAlipaySource: "", + SettingPaymentVisibleMethodWxpaySource: "", + SettingPaymentVisibleMethodAlipayEnabled: "false", + SettingPaymentVisibleMethodWxpayEnabled: "false", + openAIAdvancedSchedulerSettingKey: "false", + SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false", + SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false", + SettingKeyOpenAIAdvancedSchedulerLBTopK: "", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "", + SettingKeyOpenAIAdvancedSchedulerWeightLoad: "", + SettingKeyOpenAIAdvancedSchedulerWeightQueue: "", + SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "", + SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "", + SettingKeyOpenAIAdvancedSchedulerWeightReset: "", + SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "", + SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "", + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "", SettingKeyAllowUserViewErrorRequests: "false", } @@ -3769,6 +3810,29 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true" result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true" result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true" + result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true" + result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true" + result.OpenAIAdvancedSchedulerLBTopK = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + result.OpenAIAdvancedSchedulerWeightPriority = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) + result.OpenAIAdvancedSchedulerWeightLoad = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) + result.OpenAIAdvancedSchedulerWeightQueue = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) + result.OpenAIAdvancedSchedulerWeightErrorRate = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) + result.OpenAIAdvancedSchedulerWeightTTFT = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) + result.OpenAIAdvancedSchedulerWeightReset = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightReset]) + result.OpenAIAdvancedSchedulerWeightQuotaHeadroom = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) + result.OpenAIAdvancedSchedulerWeightPreviousResponse = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) + result.OpenAIAdvancedSchedulerWeightSessionSticky = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) + result.OpenAIAdvancedSchedulerEffectiveLBTopK = s.openAIAdvancedSchedulerEffectiveLBTopK() + effectiveWeights := s.openAIAdvancedSchedulerEffectiveWeights() + result.OpenAIAdvancedSchedulerEffectiveWeightPriority = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Priority) + result.OpenAIAdvancedSchedulerEffectiveWeightLoad = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Load) + result.OpenAIAdvancedSchedulerEffectiveWeightQueue = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Queue) + result.OpenAIAdvancedSchedulerEffectiveWeightErrorRate = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.ErrorRate) + result.OpenAIAdvancedSchedulerEffectiveWeightTTFT = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.TTFT) + result.OpenAIAdvancedSchedulerEffectiveWeightReset = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Reset) + result.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.QuotaHeadroom) + result.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.PreviousResponse) + result.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.SessionSticky) // 余额、订阅到期与账号限额通知 result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true" @@ -3841,6 +3905,93 @@ func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (s return normalized, nil } +func (s *SettingService) openAIAdvancedSchedulerEffectiveLBTopK() string { + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 { + return strconv.Itoa(s.cfg.Gateway.OpenAIWS.LBTopK) + } + return "7" +} + +func (s *SettingService) openAIAdvancedSchedulerEffectiveWeights() config.GatewayOpenAIWSSchedulerScoreWeights { + defaults := config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 1.0, + Load: 1.0, + Queue: 0.7, + ErrorRate: 0.8, + TTFT: 0.5, + Reset: 0.0, + QuotaHeadroom: 0.0, + PreviousResponse: 5.0, + SessionSticky: 3.0, + } + if s == nil || s.cfg == nil { + return defaults + } + + weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights + baseSum := weights.Priority + weights.Load + weights.Queue + weights.ErrorRate + weights.TTFT + weights.QuotaHeadroom + if baseSum <= 0 { + return defaults + } + return weights +} + +func formatOpenAIAdvancedSchedulerFloat(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error { + lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK) + if err != nil { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty") + } + settings.OpenAIAdvancedSchedulerLBTopK = lbTopK + + weights := []*string{ + &settings.OpenAIAdvancedSchedulerWeightPriority, + &settings.OpenAIAdvancedSchedulerWeightLoad, + &settings.OpenAIAdvancedSchedulerWeightQueue, + &settings.OpenAIAdvancedSchedulerWeightErrorRate, + &settings.OpenAIAdvancedSchedulerWeightTTFT, + &settings.OpenAIAdvancedSchedulerWeightReset, + &settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, + &settings.OpenAIAdvancedSchedulerWeightPreviousResponse, + &settings.OpenAIAdvancedSchedulerWeightSessionSticky, + } + for _, target := range weights { + normalized, err := normalizeOptionalNonNegativeFloatString(*target) + if err != nil { + return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must be non-negative numbers or empty") + } + *target = normalized + } + return nil +} + +func normalizeOptionalPositiveIntString(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return "", fmt.Errorf("invalid positive integer") + } + return strconv.Itoa(value), nil +} + +func normalizeOptionalNonNegativeFloatString(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { + return "", fmt.Errorf("invalid non-negative float") + } + return strconv.FormatFloat(value, 'f', -1, 64), nil +} + func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 379bf9bc04..a47e1379f7 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -49,6 +49,42 @@ func (s *settingUpdateRepoStub) Delete(ctx context.Context, key string) error { panic("unexpected Delete call") } +type settingGetAllRepoStub struct { + values map[string]string +} + +func (s *settingGetAllRepoStub) Get(ctx context.Context, key string) (*Setting, error) { + panic("unexpected Get call") +} + +func (s *settingGetAllRepoStub) GetValue(ctx context.Context, key string) (string, error) { + panic("unexpected GetValue call") +} + +func (s *settingGetAllRepoStub) Set(ctx context.Context, key, value string) error { + panic("unexpected Set call") +} + +func (s *settingGetAllRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + panic("unexpected GetMultiple call") +} + +func (s *settingGetAllRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error { + panic("unexpected SetMultiple call") +} + +func (s *settingGetAllRepoStub) GetAll(ctx context.Context) (map[string]string, error) { + out := make(map[string]string, len(s.values)) + for key, value := range s.values { + out[key] = value + } + return out, nil +} + +func (s *settingGetAllRepoStub) Delete(ctx context.Context, key string) error { + panic("unexpected Delete call") +} + type settingAntigravityUARepoStub struct { values map[string]string } @@ -261,15 +297,30 @@ func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { } func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + defer resetOpenAIAdvancedSchedulerSettingCacheForTest() + repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) err := svc.UpdateSettings(context.Background(), &SystemSettings{ - PaymentVisibleMethodAlipaySource: "alipay", - PaymentVisibleMethodWxpaySource: "easypay", - PaymentVisibleMethodAlipayEnabled: true, - PaymentVisibleMethodWxpayEnabled: false, - OpenAIAdvancedSchedulerEnabled: true, + PaymentVisibleMethodAlipaySource: "alipay", + PaymentVisibleMethodWxpaySource: "easypay", + PaymentVisibleMethodAlipayEnabled: true, + PaymentVisibleMethodWxpayEnabled: false, + OpenAIAdvancedSchedulerEnabled: true, + OpenAIAdvancedSchedulerStickyWeightedEnabled: true, + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: true, + OpenAIAdvancedSchedulerLBTopK: " 3 ", + OpenAIAdvancedSchedulerWeightPriority: "2.50", + OpenAIAdvancedSchedulerWeightLoad: "0", + OpenAIAdvancedSchedulerWeightQueue: "0.75", + OpenAIAdvancedSchedulerWeightErrorRate: "1.25", + OpenAIAdvancedSchedulerWeightTTFT: "0.5", + OpenAIAdvancedSchedulerWeightReset: "", + OpenAIAdvancedSchedulerWeightQuotaHeadroom: "0.2", + OpenAIAdvancedSchedulerWeightPreviousResponse: "8", + OpenAIAdvancedSchedulerWeightSessionSticky: "4", }) require.NoError(t, err) require.Equal(t, VisibleMethodSourceOfficialAlipay, repo.updates[SettingPaymentVisibleMethodAlipaySource]) @@ -277,6 +328,49 @@ func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler require.Equal(t, "true", repo.updates[SettingPaymentVisibleMethodAlipayEnabled]) require.Equal(t, "false", repo.updates[SettingPaymentVisibleMethodWxpayEnabled]) require.Equal(t, "true", repo.updates[openAIAdvancedSchedulerSettingKey]) + require.Equal(t, "true", repo.updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled]) + require.Equal(t, "true", repo.updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled]) + require.Equal(t, "3", repo.updates[SettingKeyOpenAIAdvancedSchedulerLBTopK]) + require.Equal(t, "2.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority]) + require.Equal(t, "0", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad]) + require.Equal(t, "0.75", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue]) + require.Equal(t, "1.25", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate]) + require.Equal(t, "0.5", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT]) + require.Equal(t, "", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightReset]) + require.Equal(t, "0.2", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom]) + require.Equal(t, "8", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse]) + require.Equal(t, "4", repo.updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky]) +} + +func TestSettingService_GetAllSettings_OpenAIAdvancedSchedulerEffectiveValuesUseConfig(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.LBTopK = 13 + cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{ + Priority: 2, + Load: 3, + Queue: 4, + ErrorRate: 5, + TTFT: 6, + Reset: 7, + QuotaHeadroom: 8, + PreviousResponse: 9, + SessionSticky: 10, + } + svc := NewSettingService(&settingGetAllRepoStub{values: map[string]string{ + SettingKeyOpenAIAdvancedSchedulerLBTopK: "3", + SettingKeyOpenAIAdvancedSchedulerWeightPriority: "99", + SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "88", + }}, cfg) + + settings, err := svc.GetAllSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, "3", settings.OpenAIAdvancedSchedulerLBTopK) + require.Equal(t, "99", settings.OpenAIAdvancedSchedulerWeightPriority) + require.Equal(t, "88", settings.OpenAIAdvancedSchedulerWeightSessionSticky) + require.Equal(t, "13", settings.OpenAIAdvancedSchedulerEffectiveLBTopK) + require.Equal(t, "2", settings.OpenAIAdvancedSchedulerEffectiveWeightPriority) + require.Equal(t, "3", settings.OpenAIAdvancedSchedulerEffectiveWeightLoad) + require.Equal(t, "10", settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky) } func TestSettingService_UpdateSettings_AntigravityUserAgentVersion(t *testing.T) { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index ac225e1b14..465760a445 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -218,7 +218,29 @@ type SystemSettings struct { PaymentVisibleMethodWxpayEnabled bool // OpenAI 账号调度 - OpenAIAdvancedSchedulerEnabled bool + OpenAIAdvancedSchedulerEnabled bool + OpenAIAdvancedSchedulerStickyWeightedEnabled bool + OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool + OpenAIAdvancedSchedulerLBTopK string + OpenAIAdvancedSchedulerWeightPriority string + OpenAIAdvancedSchedulerWeightLoad string + OpenAIAdvancedSchedulerWeightQueue string + OpenAIAdvancedSchedulerWeightErrorRate string + OpenAIAdvancedSchedulerWeightTTFT string + OpenAIAdvancedSchedulerWeightReset string + OpenAIAdvancedSchedulerWeightQuotaHeadroom string + OpenAIAdvancedSchedulerWeightPreviousResponse string + OpenAIAdvancedSchedulerWeightSessionSticky string + OpenAIAdvancedSchedulerEffectiveLBTopK string + OpenAIAdvancedSchedulerEffectiveWeightPriority string + OpenAIAdvancedSchedulerEffectiveWeightLoad string + OpenAIAdvancedSchedulerEffectiveWeightQueue string + OpenAIAdvancedSchedulerEffectiveWeightErrorRate string + OpenAIAdvancedSchedulerEffectiveWeightTTFT string + OpenAIAdvancedSchedulerEffectiveWeightReset string + OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string + OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string + OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string // 余额不足提醒 BalanceLowNotifyEnabled bool diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 44fbe29187..457775870d 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -606,6 +606,28 @@ export interface SystemSettings { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_advanced_scheduler_enabled?: boolean; + openai_advanced_scheduler_sticky_weighted_enabled?: boolean; + openai_advanced_scheduler_subscription_priority_enabled?: boolean; + openai_advanced_scheduler_lb_top_k?: string; + openai_advanced_scheduler_weight_priority?: string; + openai_advanced_scheduler_weight_load?: string; + openai_advanced_scheduler_weight_queue?: string; + openai_advanced_scheduler_weight_error_rate?: string; + openai_advanced_scheduler_weight_ttft?: string; + openai_advanced_scheduler_weight_reset?: string; + openai_advanced_scheduler_weight_quota_headroom?: string; + openai_advanced_scheduler_weight_previous_response?: string; + openai_advanced_scheduler_weight_session_sticky?: string; + openai_advanced_scheduler_effective_lb_top_k?: string; + openai_advanced_scheduler_effective_weight_priority?: string; + openai_advanced_scheduler_effective_weight_load?: string; + openai_advanced_scheduler_effective_weight_queue?: string; + openai_advanced_scheduler_effective_weight_error_rate?: string; + openai_advanced_scheduler_effective_weight_ttft?: string; + openai_advanced_scheduler_effective_weight_reset?: string; + openai_advanced_scheduler_effective_weight_quota_headroom?: string; + openai_advanced_scheduler_effective_weight_previous_response?: string; + openai_advanced_scheduler_effective_weight_session_sticky?: string; // 余额、订阅到期与账号限额通知 balance_low_notify_enabled: boolean; @@ -855,6 +877,18 @@ export interface UpdateSettingsRequest { payment_visible_method_alipay_enabled?: boolean; payment_visible_method_wxpay_enabled?: boolean; openai_advanced_scheduler_enabled?: boolean; + openai_advanced_scheduler_sticky_weighted_enabled?: boolean; + openai_advanced_scheduler_subscription_priority_enabled?: boolean; + openai_advanced_scheduler_lb_top_k?: string; + openai_advanced_scheduler_weight_priority?: string; + openai_advanced_scheduler_weight_load?: string; + openai_advanced_scheduler_weight_queue?: string; + openai_advanced_scheduler_weight_error_rate?: string; + openai_advanced_scheduler_weight_ttft?: string; + openai_advanced_scheduler_weight_reset?: string; + openai_advanced_scheduler_weight_quota_headroom?: string; + openai_advanced_scheduler_weight_previous_response?: string; + openai_advanced_scheduler_weight_session_sticky?: string; // 余额、订阅到期与账号限额通知 balance_low_notify_enabled?: boolean; balance_low_notify_threshold?: number; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 46ace87051..259801d7ad 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3265,6 +3265,7 @@ export default { priority: 'Priority', billingRateMultiplier: 'Billing Rate', weight: 'Weight', + schedulerScore: 'Scheduler Score', status: 'Status', schedulable: 'Schedulable', todayStats: 'Today Stats', @@ -3276,6 +3277,12 @@ export default { expiresAt: 'Expires At', actions: 'Actions' }, + schedulerScore: { + baseShort: 'Base', + stickyShort: 'Sticky', + ungrouped: 'Ungrouped', + hint: 'Displayed as "group / base score / sticky bonus". The base score is computed within the current filtered candidate set and includes priority, load, queue depth, error rate, first-token latency, reset window, quota headroom, and related factors. The sticky bonus applies only when sticky weighting is enabled for previous_response_id or session_hash. Higher scores are preferred.' + }, usageWindowsHint: '"5h / 7d" are the upstream account\'s official rolling usage windows (e.g. OpenAI ChatGPT, Claude). They are imposed by the upstream provider on the account itself — not configured by sub2api, and unrelated to the models you map. Usage resets automatically once each window rolls over, and the limit cannot be lifted from within sub2api.', allPrivacyModes: 'All Privacy States', privacyUnset: 'Unset', @@ -6724,7 +6731,24 @@ export default { }, openaiExperimentalScheduler: { title: 'OpenAI experimental scheduler policy', - description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability." + description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability.", + 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', + subscriptionPriorityDescription: 'When enabled, the scheduler scores ChatGPT subscription accounts first and falls back to non-subscription accounts only if no subscription slot can be acquired.', + weightsTitle: 'Scheduler weight overrides', + weightsDescription: 'Blank values use config/environment values; when config is not set, built-in defaults apply. Non-blank page settings take priority.', + defaultPlaceholder: 'config/default: {value}', + topKLabel: 'TopK', + priorityWeight: 'Priority', + loadWeight: 'Load', + queueWeight: 'Queue', + errorRateWeight: 'Error rate', + ttftWeight: 'TTFT', + resetWeight: 'Reset window', + quotaHeadroomWeight: 'Quota headroom', + previousResponseWeight: 'previous_response sticky', + sessionStickyWeight: 'session_hash sticky' }, usageRecords: { title: 'Usage Records', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 34778ff93b..2e8d9f83fc 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3296,6 +3296,7 @@ export default { priority: '优先级', billingRateMultiplier: '账号倍率', weight: '权重', + schedulerScore: '调度权值', status: '状态', schedulable: '调度', todayStats: '今日统计', @@ -3307,6 +3308,12 @@ export default { expiresAt: '过期时间', actions: '操作' }, + schedulerScore: { + baseShort: '普通', + stickyShort: '粘性', + ungrouped: '未分组', + hint: '显示格式为“分组名 / 基础分 / 粘性加分”。基础分按当前筛选条件限定的候选账号计算,包含优先级、负载、排队、错误率、首包延迟、重置窗口、额度余量等因子;粘性加分只在开启粘性加权时用于 previous_response_id 或 session_hash。分数越大越优先。' + }, usageWindowsHint: '“5h / 7d”是上游账号(如 OpenAI ChatGPT、Claude)官方的滚动用量窗口限制,由上游对账号设定,并非 sub2api 配置,也与你映射的模型无关。窗口滚动到期后用量会自动重置,无法在 sub2api 端解除该限制。', allPrivacyModes: '全部Privacy状态', privacyUnset: '未设置', @@ -6878,7 +6885,24 @@ export default { }, openaiExperimentalScheduler: { title: 'OpenAI 实验调度策略', - description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。' + description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。', + stickyWeightedTitle: '粘性加权', + stickyWeightedDescription: '开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。', + subscriptionPriorityTitle: '订阅优先', + subscriptionPriorityDescription: '开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。', + weightsTitle: '调度权值覆盖', + weightsDescription: '留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。', + defaultPlaceholder: '配置/默认:{value}', + topKLabel: 'TopK', + priorityWeight: '优先级', + loadWeight: '负载', + queueWeight: '排队', + errorRateWeight: '错误率', + ttftWeight: '首包延迟', + resetWeight: '重置窗口', + quotaHeadroomWeight: '额度余量', + previousResponseWeight: 'previous_response 粘性', + sessionStickyWeight: 'session_hash 粘性' }, usageRecords: { title: '使用记录', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 315db76224..cff836d041 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -857,6 +857,13 @@ export interface Account { concurrency: number load_factor?: number | null current_concurrency?: number // Real-time concurrency count from Redis + scheduler_score?: { + base_score: number + sticky_score?: number + sticky_score_infinity?: boolean + sticky_weighted_enabled: boolean + } | null + scheduler_scores?: AccountSchedulerGroupScore[] | null priority: number rate_multiplier?: number // Account billing multiplier (>=0, 0 means free) status: 'active' | 'inactive' | 'error' @@ -947,6 +954,16 @@ export interface Account { parent_chatgpt_account_id?: string } +export interface AccountSchedulerGroupScore { + group_id?: number | null + group_name?: string + group_priority?: number | null + base_score: number + sticky_score?: number + sticky_score_infinity?: boolean + sticky_weighted_enabled: boolean +} + // Account Usage types export interface WindowStats { requests: number diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index e8cd05cb2b..bd8fd6067a 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -322,6 +322,29 @@ + + @@ -441,7 +464,7 @@ import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfil import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { formatDateTime, formatRelativeTime } from '@/utils/format' import { proxyExpiryBadgeClass, proxyExpiryLabelKey } from '@/utils/proxyExpiry' -import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types' +import type { Account, AccountPlatform, AccountSchedulerGroupScore, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types' const { t } = useI18n() const appStore = useAppStore() @@ -643,6 +666,29 @@ const autoRefreshIntervalLabel = (sec: number) => { return `${sec}s` } +const formatSchedulerScore = (value: unknown): string => { + const num = Number(value) + if (!Number.isFinite(num)) return '-' + return num.toFixed(6).replace(/\.?0+$/, '') +} + +const formatStickySchedulerScore = (score: AccountSchedulerGroupScore): string => { + if (!score) return '-' + if (score.sticky_score_infinity) return '+∞' + return formatSchedulerScore(score.sticky_score) +} + +const getSchedulerScoreRows = (account: Account): AccountSchedulerGroupScore[] => { + if (!Array.isArray(account.scheduler_scores)) return [] + return account.scheduler_scores.filter(score => score.group_id != null) +} + +const formatSchedulerScoreGroup = (score: AccountSchedulerGroupScore): string => { + if ('group_name' in score && score.group_name) return score.group_name + if ('group_id' in score && score.group_id != null) return `#${score.group_id}` + return '-' +} + const loadSavedColumns = () => { try { const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY) @@ -1166,6 +1212,7 @@ const allColumns = computed(() => { { key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false }, { key: 'proxy', label: t('admin.accounts.columns.proxy'), sortable: false }, { key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true }, + { key: 'scheduler_score', label: t('admin.accounts.columns.schedulerScore'), sortable: false }, { key: 'rate_multiplier', label: t('admin.accounts.columns.billingRateMultiplier'), sortable: true }, { key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true }, { key: 'created_at', label: t('admin.accounts.columns.createdAt'), sortable: true }, diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 650c169c92..78f642d761 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -4014,6 +4014,81 @@ + +
+
+ +

+ {{ + t("admin.settings.openaiExperimentalScheduler.stickyWeightedDescription") + }} +

+
+ +
+ +
+
+ +

+ {{ + t("admin.settings.openaiExperimentalScheduler.subscriptionPriorityDescription") + }} +

+
+ +
+ +
+
+ +

+ {{ + t("admin.settings.openaiExperimentalScheduler.weightsDescription") + }} +

+
+ +
+ +
+
@@ -7903,6 +7978,18 @@ type SettingsForm = Omit< google_oauth_client_secret: string; force_email_on_third_party_signup: boolean; openai_advanced_scheduler_enabled: boolean; + openai_advanced_scheduler_sticky_weighted_enabled: boolean; + openai_advanced_scheduler_subscription_priority_enabled: boolean; + openai_advanced_scheduler_lb_top_k: string; + openai_advanced_scheduler_weight_priority: string; + openai_advanced_scheduler_weight_load: string; + openai_advanced_scheduler_weight_queue: string; + openai_advanced_scheduler_weight_error_rate: string; + openai_advanced_scheduler_weight_ttft: string; + openai_advanced_scheduler_weight_reset: string; + openai_advanced_scheduler_weight_quota_headroom: string; + openai_advanced_scheduler_weight_previous_response: string; + openai_advanced_scheduler_weight_session_sticky: string; // 系统全局平台限额 map;form 内始终归一化为全 4 平台对象(模板非空绑定依赖此不变量) default_platform_quotas: DefaultPlatformQuotasMap; }; @@ -8094,6 +8181,18 @@ const form = reactive({ // 分组隔离 allow_ungrouped_key_scheduling: false, openai_advanced_scheduler_enabled: false, + openai_advanced_scheduler_sticky_weighted_enabled: false, + openai_advanced_scheduler_subscription_priority_enabled: false, + openai_advanced_scheduler_lb_top_k: "", + openai_advanced_scheduler_weight_priority: "", + openai_advanced_scheduler_weight_load: "", + openai_advanced_scheduler_weight_queue: "", + openai_advanced_scheduler_weight_error_rate: "", + openai_advanced_scheduler_weight_ttft: "", + openai_advanced_scheduler_weight_reset: "", + openai_advanced_scheduler_weight_quota_headroom: "", + openai_advanced_scheduler_weight_previous_response: "", + openai_advanced_scheduler_weight_session_sticky: "", // Gateway forwarding behavior enable_fingerprint_unification: true, enable_metadata_passthrough: false, @@ -8131,6 +8230,103 @@ const form = reactive({ allow_user_view_error_requests: false, }); +type OpenAIAdvancedSchedulerOverrideKey = + | "openai_advanced_scheduler_lb_top_k" + | "openai_advanced_scheduler_weight_priority" + | "openai_advanced_scheduler_weight_load" + | "openai_advanced_scheduler_weight_queue" + | "openai_advanced_scheduler_weight_error_rate" + | "openai_advanced_scheduler_weight_ttft" + | "openai_advanced_scheduler_weight_reset" + | "openai_advanced_scheduler_weight_quota_headroom" + | "openai_advanced_scheduler_weight_previous_response" + | "openai_advanced_scheduler_weight_session_sticky"; + +type OpenAIAdvancedSchedulerEffectiveKey = + | "openai_advanced_scheduler_effective_lb_top_k" + | "openai_advanced_scheduler_effective_weight_priority" + | "openai_advanced_scheduler_effective_weight_load" + | "openai_advanced_scheduler_effective_weight_queue" + | "openai_advanced_scheduler_effective_weight_error_rate" + | "openai_advanced_scheduler_effective_weight_ttft" + | "openai_advanced_scheduler_effective_weight_reset" + | "openai_advanced_scheduler_effective_weight_quota_headroom" + | "openai_advanced_scheduler_effective_weight_previous_response" + | "openai_advanced_scheduler_effective_weight_session_sticky"; + +const openAIAdvancedSchedulerWeightFields = computed< + Array<{ + key: OpenAIAdvancedSchedulerOverrideKey; + label: string; + placeholder: string; + }> +>(() => { + const placeholder = ( + effectiveKey: OpenAIAdvancedSchedulerEffectiveKey, + fallbackValue: string, + ) => { + const effectiveValue = String( + (form as Record)[effectiveKey] ?? "", + ).trim(); + return t("admin.settings.openaiExperimentalScheduler.defaultPlaceholder", { + value: effectiveValue || fallbackValue, + }); + }; + + return [ + { + key: "openai_advanced_scheduler_lb_top_k", + label: t("admin.settings.openaiExperimentalScheduler.topKLabel"), + placeholder: placeholder("openai_advanced_scheduler_effective_lb_top_k", "7"), + }, + { + key: "openai_advanced_scheduler_weight_priority", + label: t("admin.settings.openaiExperimentalScheduler.priorityWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_priority", "1"), + }, + { + key: "openai_advanced_scheduler_weight_load", + label: t("admin.settings.openaiExperimentalScheduler.loadWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_load", "1"), + }, + { + key: "openai_advanced_scheduler_weight_queue", + label: t("admin.settings.openaiExperimentalScheduler.queueWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_queue", "0.7"), + }, + { + key: "openai_advanced_scheduler_weight_error_rate", + label: t("admin.settings.openaiExperimentalScheduler.errorRateWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_error_rate", "0.8"), + }, + { + key: "openai_advanced_scheduler_weight_ttft", + label: t("admin.settings.openaiExperimentalScheduler.ttftWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_ttft", "0.5"), + }, + { + key: "openai_advanced_scheduler_weight_reset", + label: t("admin.settings.openaiExperimentalScheduler.resetWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_reset", "0"), + }, + { + key: "openai_advanced_scheduler_weight_quota_headroom", + label: t("admin.settings.openaiExperimentalScheduler.quotaHeadroomWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_quota_headroom", "0"), + }, + { + key: "openai_advanced_scheduler_weight_previous_response", + label: t("admin.settings.openaiExperimentalScheduler.previousResponseWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_previous_response", "5"), + }, + { + key: "openai_advanced_scheduler_weight_session_sticky", + label: t("admin.settings.openaiExperimentalScheduler.sessionStickyWeight"), + placeholder: placeholder("openai_advanced_scheduler_effective_weight_session_sticky", "3"), + }, + ]; +}); + const authSourceDefaults = reactive( buildAuthSourceDefaultsState({}), ); @@ -9359,6 +9555,30 @@ async function saveSettings() { form.payment_cancel_rate_limit_window_mode, payment_alipay_force_qrcode: form.payment_alipay_force_qrcode, openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled, + openai_advanced_scheduler_sticky_weighted_enabled: + form.openai_advanced_scheduler_sticky_weighted_enabled, + openai_advanced_scheduler_subscription_priority_enabled: + form.openai_advanced_scheduler_subscription_priority_enabled, + openai_advanced_scheduler_lb_top_k: + form.openai_advanced_scheduler_lb_top_k.trim(), + openai_advanced_scheduler_weight_priority: + form.openai_advanced_scheduler_weight_priority.trim(), + openai_advanced_scheduler_weight_load: + form.openai_advanced_scheduler_weight_load.trim(), + openai_advanced_scheduler_weight_queue: + form.openai_advanced_scheduler_weight_queue.trim(), + openai_advanced_scheduler_weight_error_rate: + form.openai_advanced_scheduler_weight_error_rate.trim(), + openai_advanced_scheduler_weight_ttft: + form.openai_advanced_scheduler_weight_ttft.trim(), + openai_advanced_scheduler_weight_reset: + form.openai_advanced_scheduler_weight_reset.trim(), + openai_advanced_scheduler_weight_quota_headroom: + form.openai_advanced_scheduler_weight_quota_headroom.trim(), + openai_advanced_scheduler_weight_previous_response: + form.openai_advanced_scheduler_weight_previous_response.trim(), + openai_advanced_scheduler_weight_session_sticky: + form.openai_advanced_scheduler_weight_session_sticky.trim(), // 余额、订阅到期与账号限额通知 balance_low_notify_enabled: form.balance_low_notify_enabled, balance_low_notify_threshold: diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index e458728b66..e28e186e05 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -161,6 +161,23 @@ vi.mock("vue-i18n", async () => { "admin.settings.payment.findProvider": "查看支持的支付方式", "admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略", "admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。", + "admin.settings.openaiExperimentalScheduler.stickyWeightedTitle": "粘性加权", + "admin.settings.openaiExperimentalScheduler.stickyWeightedDescription": "开启后 previous_response_id 和 session_hash 粘性进入高级调度打分;关闭时仍按旧逻辑硬命中粘性账号。", + "admin.settings.openaiExperimentalScheduler.subscriptionPriorityTitle": "订阅优先", + "admin.settings.openaiExperimentalScheduler.subscriptionPriorityDescription": "开启后先在 ChatGPT 订阅账号池中按权值选取;订阅池拿不到席位时再回退到非订阅账号池。", + "admin.settings.openaiExperimentalScheduler.weightsTitle": "调度权值覆盖", + "admin.settings.openaiExperimentalScheduler.weightsDescription": "留空时使用配置/环境变量值;配置未设置时使用内置默认值。页面非空设置优先。", + "admin.settings.openaiExperimentalScheduler.defaultPlaceholder": "配置/默认:{value}", + "admin.settings.openaiExperimentalScheduler.topKLabel": "TopK", + "admin.settings.openaiExperimentalScheduler.priorityWeight": "优先级", + "admin.settings.openaiExperimentalScheduler.loadWeight": "负载", + "admin.settings.openaiExperimentalScheduler.queueWeight": "排队", + "admin.settings.openaiExperimentalScheduler.errorRateWeight": "错误率", + "admin.settings.openaiExperimentalScheduler.ttftWeight": "首包延迟", + "admin.settings.openaiExperimentalScheduler.resetWeight": "重置窗口", + "admin.settings.openaiExperimentalScheduler.quotaHeadroomWeight": "额度余量", + "admin.settings.openaiExperimentalScheduler.previousResponseWeight": "previous_response 粘性", + "admin.settings.openaiExperimentalScheduler.sessionStickyWeight": "session_hash 粘性", "admin.settings.site.uploadImage": "上传图片", "admin.settings.site.remove": "移除", "admin.settings.platformQuota.platform": "平台", @@ -411,6 +428,28 @@ const baseSettingsResponse = { payment_visible_method_alipay_enabled: true, payment_visible_method_wxpay_enabled: true, openai_advanced_scheduler_enabled: false, + openai_advanced_scheduler_sticky_weighted_enabled: false, + openai_advanced_scheduler_subscription_priority_enabled: false, + openai_advanced_scheduler_lb_top_k: "", + openai_advanced_scheduler_weight_priority: "", + openai_advanced_scheduler_weight_load: "", + openai_advanced_scheduler_weight_queue: "", + openai_advanced_scheduler_weight_error_rate: "", + openai_advanced_scheduler_weight_ttft: "", + openai_advanced_scheduler_weight_reset: "", + openai_advanced_scheduler_weight_quota_headroom: "", + openai_advanced_scheduler_weight_previous_response: "", + openai_advanced_scheduler_weight_session_sticky: "", + openai_advanced_scheduler_effective_lb_top_k: "7", + openai_advanced_scheduler_effective_weight_priority: "1", + openai_advanced_scheduler_effective_weight_load: "1", + openai_advanced_scheduler_effective_weight_queue: "0.7", + openai_advanced_scheduler_effective_weight_error_rate: "0.8", + openai_advanced_scheduler_effective_weight_ttft: "0.5", + openai_advanced_scheduler_effective_weight_reset: "0", + openai_advanced_scheduler_effective_weight_quota_headroom: "0", + openai_advanced_scheduler_effective_weight_previous_response: "5", + openai_advanced_scheduler_effective_weight_session_sticky: "3", balance_low_notify_enabled: false, balance_low_notify_threshold: 0, balance_low_notify_recharge_url: "",