feat(account): 支持按 5h/7d 用量阈值自动暂停账号调度
This commit is contained in:
parent
21cd382f39
commit
ead471d64b
@ -548,6 +548,12 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
|
||||
"openai_ws_force_http",
|
||||
"openai_responses_mode",
|
||||
"openai_responses_supported",
|
||||
"codex_5h_used_percent",
|
||||
"codex_7d_used_percent",
|
||||
"auto_pause_5h_threshold",
|
||||
"auto_pause_7d_threshold",
|
||||
"auto_pause_5h_limit",
|
||||
"auto_pause_7d_limit",
|
||||
}
|
||||
filtered := make(map[string]any)
|
||||
for _, key := range keys {
|
||||
|
||||
@ -75,3 +75,22 @@ func TestBuildSchedulerMetadataAccount_KeepsSlimGroupMembership(t *testing.T) {
|
||||
require.Equal(t, int64(11), got.AccountGroups[1].GroupID)
|
||||
require.Nil(t, got.Groups)
|
||||
}
|
||||
|
||||
func TestBuildSchedulerMetadataAccount_KeepsQuotaAutoPauseFields(t *testing.T) {
|
||||
account := service.Account{
|
||||
ID: 88,
|
||||
Extra: map[string]any{
|
||||
"codex_5h_used_percent": 12.34,
|
||||
"codex_7d_used_percent": 56.78,
|
||||
"auto_pause_5h_threshold": 0.95,
|
||||
"auto_pause_7d_threshold": 0.96,
|
||||
},
|
||||
}
|
||||
|
||||
got := buildSchedulerMetadataAccount(account)
|
||||
|
||||
require.Equal(t, 12.34, got.Extra["codex_5h_used_percent"])
|
||||
require.Equal(t, 56.78, got.Extra["codex_7d_used_percent"])
|
||||
require.Equal(t, 0.95, got.Extra["auto_pause_5h_threshold"])
|
||||
require.Equal(t, 0.96, got.Extra["auto_pause_7d_threshold"])
|
||||
}
|
||||
|
||||
@ -370,7 +370,6 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
|
||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, acquireErr := s.service.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||
if acquireErr == nil && result != nil && result.Acquired {
|
||||
_ = s.service.refreshStickySessionTTL(ctx, req.GroupID, sessionHash, s.service.openAIWSSessionStickyTTL())
|
||||
@ -1154,6 +1153,7 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
||||
requiredImageCapability OpenAIImagesCapability,
|
||||
requireCompact bool,
|
||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||
ctx = s.withOpenAIQuotaAutoPauseContext(ctx)
|
||||
decision := OpenAIAccountScheduleDecision{}
|
||||
scheduler := s.getOpenAIAccountScheduler(ctx)
|
||||
if scheduler == nil {
|
||||
|
||||
@ -691,6 +691,117 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_SessionStickyRateLimite
|
||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_AutoPauseBy5hThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
primary := Account{
|
||||
ID: 35001,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
Extra: map[string]any{
|
||||
"codex_5h_used_percent": 95.0,
|
||||
"auto_pause_5h_threshold": 0.95,
|
||||
"auto_pause_5h_limit": 100,
|
||||
},
|
||||
}
|
||||
secondary := Account{ID: 35002, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
svc := &OpenAIGatewayService{accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{primary, secondary}}, cfg: &config.Config{}}
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gpt-5.1", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(35002), account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_AllowsBelow5hThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
primary := Account{
|
||||
ID: 35101,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
Extra: map[string]any{
|
||||
"codex_5h_used_percent": 80.0,
|
||||
"auto_pause_5h_threshold": 0.95,
|
||||
"auto_pause_5h_limit": 100,
|
||||
},
|
||||
}
|
||||
secondary := Account{ID: 35102, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
svc := &OpenAIGatewayService{accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{primary, secondary}}, cfg: &config.Config{}}
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gpt-5.1", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(35101), account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_AutoPauseBy7dThreshold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
primary := Account{
|
||||
ID: 35201,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
Extra: map[string]any{
|
||||
"codex_7d_used_percent": 95.0,
|
||||
"auto_pause_7d_threshold": 0.95,
|
||||
"auto_pause_7d_limit": 200,
|
||||
},
|
||||
}
|
||||
secondary := Account{ID: 35202, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
svc := &OpenAIGatewayService{accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{primary, secondary}}, cfg: &config.Config{}}
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gpt-5.1", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(35202), account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_UnconfiguredThresholdKeepsLegacyBehavior(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
primary := Account{ID: 35301, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 0, Extra: map[string]any{"codex_5h_used_percent": 99.0, "codex_7d_used_percent": 99.0}}
|
||||
secondary := Account{ID: 35302, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
svc := &OpenAIGatewayService{accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{primary, secondary}}, cfg: &config.Config{}}
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gpt-5.1", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(35301), account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_UsesGlobalDefaultThreshold(t *testing.T) {
|
||||
ctx := withOpenAIQuotaAutoPauseSettings(context.Background(), OpsOpenAIAccountQuotaAutoPauseSettings{DefaultThreshold5h: 0.95})
|
||||
primary := Account{
|
||||
ID: 35401,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Priority: 0,
|
||||
Extra: map[string]any{
|
||||
"codex_5h_used_percent": 95.0,
|
||||
"auto_pause_5h_limit": 100,
|
||||
},
|
||||
}
|
||||
secondary := Account{ID: 35402, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, Priority: 5}
|
||||
svc := &OpenAIGatewayService{accountRepo: schedulerTestOpenAIAccountRepo{accounts: []Account{primary, secondary}}, cfg: &config.Config{}}
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, nil, "", "gpt-5.1", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(35402), account.ID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_SkipsFreshlyRateLimitedSnapshotCandidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
groupID := int64(10102)
|
||||
|
||||
@ -1290,7 +1290,7 @@ func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupI
|
||||
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
||||
// SelectAccountForModelWithExclusions 选择支持指定模型的账号,同时排除指定的账号。
|
||||
func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||
return s.selectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs, false, 0, "")
|
||||
return s.selectAccountForModelWithExclusions(s.withOpenAIQuotaAutoPauseContext(ctx), groupID, sessionHash, requestedModel, excludedIDs, false, 0, "")
|
||||
}
|
||||
|
||||
// noAvailableOpenAISelectionError builds the standard "no account available" error
|
||||
@ -1327,6 +1327,18 @@ func isOpenAIAccountEligibleForRequest(ctx context.Context, account *Account, re
|
||||
if account == nil || !account.IsOpenAI() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) {
|
||||
return false
|
||||
}
|
||||
if paused, reason := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused {
|
||||
slog.Info("account_auto_paused_by_quota",
|
||||
"account_id", account.ID,
|
||||
"usage_5h_percent", readOpenAIQuotaUsedPercent(account.Extra, "5h"),
|
||||
"usage_7d_percent", readOpenAIQuotaUsedPercent(account.Extra, "7d"),
|
||||
"threshold_type", reason.window,
|
||||
"threshold", reason.threshold,
|
||||
"limit", reason.limit,
|
||||
"utilization", reason.utilization,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
|
||||
return false
|
||||
}
|
||||
@ -1339,6 +1351,134 @@ func isOpenAIAccountEligibleForRequest(ctx context.Context, account *Account, re
|
||||
return true
|
||||
}
|
||||
|
||||
type openAIQuotaAutoPauseDecision struct {
|
||||
window string
|
||||
threshold float64
|
||||
limit float64
|
||||
utilization float64
|
||||
}
|
||||
|
||||
func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account) (bool, openAIQuotaAutoPauseDecision) {
|
||||
if account == nil || !account.IsOpenAI() {
|
||||
return false, openAIQuotaAutoPauseDecision{}
|
||||
}
|
||||
threshold5h, threshold7d := resolveOpenAIQuotaAutoPauseThresholds(ctx, account)
|
||||
if threshold5h > 0 {
|
||||
if utilization, limit, ok := resolveOpenAIQuotaUtilization(account.Extra, "5h"); ok {
|
||||
if utilization >= threshold5h {
|
||||
return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: threshold5h, limit: limit, utilization: utilization}
|
||||
}
|
||||
}
|
||||
}
|
||||
if threshold7d > 0 {
|
||||
if utilization, limit, ok := resolveOpenAIQuotaUtilization(account.Extra, "7d"); ok {
|
||||
if utilization >= threshold7d {
|
||||
return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: threshold7d, limit: limit, utilization: utilization}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, openAIQuotaAutoPauseDecision{}
|
||||
}
|
||||
|
||||
func resolveOpenAIQuotaAutoPauseThresholds(ctx context.Context, account *Account) (float64, float64) {
|
||||
threshold5h, _ := resolveAccountExtraNumber(account.Extra, "auto_pause_5h_threshold")
|
||||
threshold7d, _ := resolveAccountExtraNumber(account.Extra, "auto_pause_7d_threshold")
|
||||
threshold5h = clamp01(threshold5h)
|
||||
threshold7d = clamp01(threshold7d)
|
||||
if threshold5h > 0 && threshold7d > 0 {
|
||||
return threshold5h, threshold7d
|
||||
}
|
||||
settings := openAIQuotaAutoPauseSettingsFromContext(ctx)
|
||||
if threshold5h <= 0 {
|
||||
threshold5h = clamp01(settings.DefaultThreshold5h)
|
||||
}
|
||||
if threshold7d <= 0 {
|
||||
threshold7d = clamp01(settings.DefaultThreshold7d)
|
||||
}
|
||||
return threshold5h, threshold7d
|
||||
}
|
||||
|
||||
func resolveAccountExtraNumber(extra map[string]any, keys ...string) (float64, bool) {
|
||||
if len(extra) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
for _, key := range keys {
|
||||
value, ok := extra[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case json.Number:
|
||||
parsed, err := v.Float64()
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func resolveOpenAIQuotaUtilization(extra map[string]any, window string) (float64, float64, bool) {
|
||||
limitKeys := []string{"auto_pause_" + window + "_limit", "quota_" + window + "_limit", window + "_limit"}
|
||||
if limit, ok := resolveAccountExtraNumber(extra, limitKeys...); ok && limit > 0 {
|
||||
if usage, ok := resolveAccountExtraNumber(extra, "usage_"+window); ok && usage >= 0 {
|
||||
return usage / limit, limit, true
|
||||
}
|
||||
}
|
||||
usedPercent := readOpenAIQuotaUsedPercent(extra, window)
|
||||
if usedPercent <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return usedPercent / 100, 100, true
|
||||
}
|
||||
|
||||
func readOpenAIQuotaUsedPercent(extra map[string]any, window string) float64 {
|
||||
if len(extra) == 0 {
|
||||
return 0
|
||||
}
|
||||
if value, ok := resolveAccountExtraNumber(extra, "codex_"+window+"_used_percent"); ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type openAIQuotaAutoPauseCtxKey struct{}
|
||||
|
||||
func withOpenAIQuotaAutoPauseSettings(ctx context.Context, settings OpsOpenAIAccountQuotaAutoPauseSettings) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, openAIQuotaAutoPauseCtxKey{}, settings)
|
||||
}
|
||||
|
||||
func openAIQuotaAutoPauseSettingsFromContext(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||||
if ctx == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
settings, _ := ctx.Value(openAIQuotaAutoPauseCtxKey{}).(OpsOpenAIAccountQuotaAutoPauseSettings)
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) withOpenAIQuotaAutoPauseContext(ctx context.Context) context.Context {
|
||||
if s == nil || s.settingService == nil {
|
||||
return ctx
|
||||
}
|
||||
return withOpenAIQuotaAutoPauseSettings(ctx, s.settingService.GetOpenAIQuotaAutoPauseSettings(ctx))
|
||||
}
|
||||
|
||||
// prioritizeOpenAICompactAccounts re-orders a slice so that accounts with known
|
||||
// compact support are tried first, followed by unknown, then explicitly unsupported.
|
||||
// The relative order within each tier is preserved.
|
||||
@ -1587,7 +1727,7 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool
|
||||
|
||||
// SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan.
|
||||
func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) {
|
||||
return s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs, false, "")
|
||||
return s.selectAccountWithLoadAwareness(s.withOpenAIQuotaAutoPauseContext(ctx), groupID, sessionHash, requestedModel, excludedIDs, false, "")
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability) (*AccountSelectionResult, error) {
|
||||
|
||||
@ -369,6 +369,7 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
|
||||
Aggregation: OpsAggregationSettings{
|
||||
AggregationEnabled: false,
|
||||
},
|
||||
OpenAIAccountQuotaAutoPause: OpsOpenAIAccountQuotaAutoPauseSettings{},
|
||||
IgnoreCountTokensErrors: true, // count_tokens 404 是预期行为,默认忽略
|
||||
IgnoreContextCanceled: true, // Default to true - client disconnects are not errors
|
||||
IgnoreNoAvailableAccounts: false, // Default to false - this is a real routing issue
|
||||
@ -384,6 +385,8 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold5h = clampOpsQuotaAutoPauseThreshold(cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold5h)
|
||||
cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold7d = clampOpsQuotaAutoPauseThreshold(cfg.OpenAIAccountQuotaAutoPause.DefaultThreshold7d)
|
||||
cfg.DataRetention.CleanupSchedule = strings.TrimSpace(cfg.DataRetention.CleanupSchedule)
|
||||
if cfg.DataRetention.CleanupSchedule == "" {
|
||||
cfg.DataRetention.CleanupSchedule = opsCleanupDefaultSchedule
|
||||
@ -405,6 +408,16 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
func clampOpsQuotaAutoPauseThreshold(value float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error {
|
||||
if cfg == nil {
|
||||
return errors.New("invalid config")
|
||||
@ -477,6 +490,12 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyOpsAdvancedSettings, string(raw)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cacheKey := openAIQuotaAutoPauseSettingsCacheKey(s.settingRepo)
|
||||
openAIQuotaAutoPauseSettingsSF.Forget(cacheKey)
|
||||
storeOpenAIQuotaAutoPauseSettingsCache(s.settingRepo, &cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: cfg.OpenAIAccountQuotaAutoPause,
|
||||
expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(),
|
||||
})
|
||||
|
||||
// notify cleanup service to reload schedule/enabled.
|
||||
if s.cleanupReloader != nil {
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
func TestGetOpsAdvancedSettings_DefaultHidesOpenAITokenStats(t *testing.T) {
|
||||
@ -95,3 +97,17 @@ func TestGetOpsAdvancedSettings_BackfillsNewDisplayFlagsFromDefaults(t *testing.
|
||||
t.Fatalf("DisplayAlertEvents = false, want true default backfill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOpenAIQuotaAutoPauseSettings_ReadsDefaultsFromOpsAdvancedSettings(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
repo.values[SettingKeyOpsAdvancedSettings] = `{"openai_account_quota_auto_pause":{"default_threshold_5h":0.95,"default_threshold_7d":0.9}}`
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
settings := svc.GetOpenAIQuotaAutoPauseSettings(context.Background())
|
||||
if settings.DefaultThreshold5h != 0.95 {
|
||||
t.Fatalf("DefaultThreshold5h = %v, want 0.95", settings.DefaultThreshold5h)
|
||||
}
|
||||
if settings.DefaultThreshold7d != 0.9 {
|
||||
t.Fatalf("DefaultThreshold7d = %v, want 0.9", settings.DefaultThreshold7d)
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,17 +92,23 @@ type OpsAlertRuntimeSettings struct {
|
||||
|
||||
// OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation).
|
||||
type OpsAdvancedSettings struct {
|
||||
DataRetention OpsDataRetentionSettings `json:"data_retention"`
|
||||
Aggregation OpsAggregationSettings `json:"aggregation"`
|
||||
IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"`
|
||||
IgnoreContextCanceled bool `json:"ignore_context_canceled"`
|
||||
IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"`
|
||||
IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"`
|
||||
IgnoreInsufficientBalanceErrors bool `json:"ignore_insufficient_balance_errors"`
|
||||
DisplayOpenAITokenStats bool `json:"display_openai_token_stats"`
|
||||
DisplayAlertEvents bool `json:"display_alert_events"`
|
||||
AutoRefreshEnabled bool `json:"auto_refresh_enabled"`
|
||||
AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"`
|
||||
DataRetention OpsDataRetentionSettings `json:"data_retention"`
|
||||
Aggregation OpsAggregationSettings `json:"aggregation"`
|
||||
OpenAIAccountQuotaAutoPause OpsOpenAIAccountQuotaAutoPauseSettings `json:"openai_account_quota_auto_pause"`
|
||||
IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"`
|
||||
IgnoreContextCanceled bool `json:"ignore_context_canceled"`
|
||||
IgnoreNoAvailableAccounts bool `json:"ignore_no_available_accounts"`
|
||||
IgnoreInvalidApiKeyErrors bool `json:"ignore_invalid_api_key_errors"`
|
||||
IgnoreInsufficientBalanceErrors bool `json:"ignore_insufficient_balance_errors"`
|
||||
DisplayOpenAITokenStats bool `json:"display_openai_token_stats"`
|
||||
DisplayAlertEvents bool `json:"display_alert_events"`
|
||||
AutoRefreshEnabled bool `json:"auto_refresh_enabled"`
|
||||
AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"`
|
||||
}
|
||||
|
||||
type OpsOpenAIAccountQuotaAutoPauseSettings struct {
|
||||
DefaultThreshold5h float64 `json:"default_threshold_5h"`
|
||||
DefaultThreshold7d float64 `json:"default_threshold_7d"`
|
||||
}
|
||||
|
||||
type OpsDataRetentionSettings struct {
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@ -137,6 +138,11 @@ type cachedOpenAICodexUserAgent struct {
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
type cachedOpenAIQuotaAutoPauseSettings struct {
|
||||
settings OpsOpenAIAccountQuotaAutoPauseSettings
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
const openAICodexUserAgentCacheTTL = 60 * time.Second
|
||||
const openAICodexUserAgentErrorTTL = 5 * time.Second
|
||||
const openAICodexUserAgentDBTimeout = 5 * time.Second
|
||||
@ -152,6 +158,33 @@ const openAIAllowCodexPluginCacheTTL = 60 * time.Second
|
||||
const openAIAllowCodexPluginErrorTTL = 5 * time.Second
|
||||
const openAIAllowCodexPluginDBTimeout = 5 * time.Second
|
||||
|
||||
const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second
|
||||
const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second
|
||||
const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second
|
||||
|
||||
var openAIQuotaAutoPauseSettingsCache sync.Map // map[string]*cachedOpenAIQuotaAutoPauseSettings
|
||||
var openAIQuotaAutoPauseSettingsSF singleflight.Group
|
||||
|
||||
func openAIQuotaAutoPauseSettingsCacheKey(repo SettingRepository) string {
|
||||
if repo == nil {
|
||||
return "nil"
|
||||
}
|
||||
return fmt.Sprintf("%T:%p", repo, repo)
|
||||
}
|
||||
|
||||
func loadOpenAIQuotaAutoPauseSettingsCache(repo SettingRepository) (*cachedOpenAIQuotaAutoPauseSettings, bool) {
|
||||
value, ok := openAIQuotaAutoPauseSettingsCache.Load(openAIQuotaAutoPauseSettingsCacheKey(repo))
|
||||
if !ok || value == nil {
|
||||
return nil, false
|
||||
}
|
||||
cached, ok := value.(*cachedOpenAIQuotaAutoPauseSettings)
|
||||
return cached, ok && cached != nil
|
||||
}
|
||||
|
||||
func storeOpenAIQuotaAutoPauseSettingsCache(repo SettingRepository, cached *cachedOpenAIQuotaAutoPauseSettings) {
|
||||
openAIQuotaAutoPauseSettingsCache.Store(openAIQuotaAutoPauseSettingsCacheKey(repo), cached)
|
||||
}
|
||||
|
||||
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
||||
type DefaultSubscriptionGroupReader interface {
|
||||
GetByID(ctx context.Context, id int64) (*Group, error)
|
||||
@ -2027,6 +2060,9 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
||||
enabled: settings.OpenAIAdvancedSchedulerEnabled,
|
||||
expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(),
|
||||
})
|
||||
cacheKey := openAIQuotaAutoPauseSettingsCacheKey(s.settingRepo)
|
||||
openAIQuotaAutoPauseSettingsSF.Forget(cacheKey)
|
||||
openAIQuotaAutoPauseSettingsCache.Delete(cacheKey)
|
||||
if s.cfg != nil {
|
||||
s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP)
|
||||
}
|
||||
@ -4448,6 +4484,51 @@ func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, m
|
||||
return b.min, b.max
|
||||
}
|
||||
|
||||
func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||||
if cached, ok := loadOpenAIQuotaAutoPauseSettingsCache(s.settingRepo); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.settings
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := openAIQuotaAutoPauseSettingsCacheKey(s.settingRepo)
|
||||
result, _, _ := openAIQuotaAutoPauseSettingsSF.Do(cacheKey, func() (any, error) {
|
||||
if cached, ok := loadOpenAIQuotaAutoPauseSettingsCache(s.settingRepo); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.settings, nil
|
||||
}
|
||||
}
|
||||
|
||||
settings := OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
ttl := openAIQuotaAutoPauseSettingsCacheTTL
|
||||
if s != nil && s.settingRepo != nil {
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout)
|
||||
defer cancel()
|
||||
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings)
|
||||
if err == nil {
|
||||
cfg := defaultOpsAdvancedSettings()
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil {
|
||||
normalizeOpsAdvancedSettings(cfg)
|
||||
}
|
||||
}
|
||||
settings = cfg.OpenAIAccountQuotaAutoPause
|
||||
} else {
|
||||
ttl = openAIQuotaAutoPauseSettingsErrorTTL
|
||||
}
|
||||
}
|
||||
|
||||
storeOpenAIQuotaAutoPauseSettingsCache(s.settingRepo, &cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: settings,
|
||||
expiresAt: time.Now().Add(ttl).UnixNano(),
|
||||
})
|
||||
return settings, nil
|
||||
})
|
||||
|
||||
settings, _ := result.(OpsOpenAIAccountQuotaAutoPauseSettings)
|
||||
return settings
|
||||
}
|
||||
|
||||
// GetRectifierSettings 获取请求整流器配置
|
||||
func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings)
|
||||
|
||||
@ -1787,6 +1787,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="account?.platform === 'openai'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.autoPause5hThreshold') }}</label>
|
||||
<input
|
||||
v-model.number="autoPause5hThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
data-testid="auto-pause-5h-threshold"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.autoPauseThresholdHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.autoPause7dThreshold') }}</label>
|
||||
<input
|
||||
v-model.number="autoPause7dThreshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
class="input"
|
||||
data-testid="auto-pause-7d-threshold"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.autoPauseThresholdHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配额控制 (Anthropic OAuth/SetupToken: 亲和 + 窗口费用 + 会话 + RPM 等) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && (account?.type === 'oauth' || account?.type === 'setup-token')"
|
||||
@ -2447,6 +2479,8 @@ const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(false)
|
||||
const autoPause5hThreshold = ref<number | null>(null)
|
||||
const autoPause7dThreshold = ref<number | null>(null)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
@ -2862,9 +2896,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
// Load mixed scheduling setting (only for antigravity accounts)
|
||||
mixedScheduling.value = false
|
||||
allowOverages.value = false
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
allowOverages.value = extra?.allow_overages === true
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
allowOverages.value = extra?.allow_overages === true
|
||||
autoPause5hThreshold.value = typeof extra?.auto_pause_5h_threshold === 'number' ? extra.auto_pause_5h_threshold * 100 : null
|
||||
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
@ -3987,9 +4023,9 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
// For OpenAI OAuth/API Key accounts, handle passthrough mode in extra
|
||||
if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'apikey')) {
|
||||
const currentExtra = (props.account.extra as Record<string, unknown>) || {}
|
||||
const newExtra: Record<string, unknown> = { ...currentExtra }
|
||||
const hadCodexCLIOnlyEnabled = currentExtra.codex_cli_only === true
|
||||
if (props.account.type === 'oauth') {
|
||||
newExtra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value
|
||||
@ -4011,15 +4047,25 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
newExtra.openai_compact_mode = openAICompactMode.value
|
||||
}
|
||||
if (props.account.type === 'apikey') {
|
||||
if (props.account.type === 'apikey') {
|
||||
if (!openAITextGenerationCapabilityEnabled.value || openAIResponsesMode.value === 'auto') {
|
||||
delete newExtra.openai_responses_mode
|
||||
} else {
|
||||
newExtra.openai_responses_mode = openAIResponsesMode.value
|
||||
}
|
||||
}
|
||||
}
|
||||
if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) {
|
||||
newExtra.auto_pause_5h_threshold = autoPause5hThreshold.value / 100
|
||||
} else {
|
||||
delete newExtra.auto_pause_5h_threshold
|
||||
}
|
||||
if (autoPause7dThreshold.value != null && autoPause7dThreshold.value > 0) {
|
||||
newExtra.auto_pause_7d_threshold = autoPause7dThreshold.value / 100
|
||||
} else {
|
||||
delete newExtra.auto_pause_7d_threshold
|
||||
}
|
||||
|
||||
delete newExtra.codex_image_generation_bridge_enabled
|
||||
delete newExtra.codex_image_generation_bridge_enabled
|
||||
if (codexImageGenerationBridgeMode.value === 'inherit') {
|
||||
delete newExtra.codex_image_generation_bridge
|
||||
} else {
|
||||
|
||||
@ -330,6 +330,28 @@ describe('EditAccountModal', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('submits OpenAI quota auto-pause thresholds in extra', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
auto_pause_5h_threshold: 0.9,
|
||||
auto_pause_7d_threshold: 0.8
|
||||
}
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
await wrapper.get('[data-testid="auto-pause-5h-threshold"]').setValue('95')
|
||||
await wrapper.get('[data-testid="auto-pause-7d-threshold"]').setValue('96')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_5h_threshold).toBe(0.95)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_7d_threshold).toBe(0.96)
|
||||
})
|
||||
|
||||
it('keeps at least one OpenAI APIKey endpoint capability selected', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset()
|
||||
|
||||
@ -3475,6 +3475,9 @@ export default {
|
||||
'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
|
||||
autoPauseOnExpired: 'Auto Pause On Expired',
|
||||
autoPauseOnExpiredDesc: 'When enabled, the account will auto pause scheduling after it expires',
|
||||
autoPause5hThreshold: '5h Usage Threshold (%)',
|
||||
autoPause7dThreshold: '7d Usage Threshold (%)',
|
||||
autoPauseThresholdHint: 'Leave empty or set 0 to disable. Reaching the threshold only skips the account during scheduling and does not modify schedulable.',
|
||||
// Quota control (Anthropic OAuth/SetupToken only)
|
||||
quotaControl: {
|
||||
title: 'Quota Control',
|
||||
|
||||
@ -3613,6 +3613,9 @@ export default {
|
||||
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
|
||||
autoPauseOnExpired: '过期自动暂停调度',
|
||||
autoPauseOnExpiredDesc: '启用后,账号过期将自动暂停调度',
|
||||
autoPause5hThreshold: '5h 用量阈值(%)',
|
||||
autoPause7dThreshold: '7d 用量阈值(%)',
|
||||
autoPauseThresholdHint: '填 0 或留空表示不启用;达到阈值后仅在调度时跳过账号,不修改 schedulable。',
|
||||
// Quota control (Anthropic OAuth/SetupToken only)
|
||||
quotaControl: {
|
||||
title: '配额控制',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user