Merge pull request #3441 from deqiying/feature/openai-quota-headroom-scheduler

新增 OpenAI 剩余额度调度权重
This commit is contained in:
Wesley Liddick
2026-06-29 09:23:32 +08:00
committed by GitHub
5 changed files with 223 additions and 22 deletions
+7 -2
View File
@@ -964,6 +964,8 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
// Reset 倾向「会话窗口最早重置」的账号(use-it-or-lose-it)。
// >0 时,剩余重置时间越短的账号得分越高,从而被优先用尽。默认 0(关闭,不改变原有行为)。
Reset float64 `mapstructure:"reset"`
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
}
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
@@ -1884,6 +1886,7 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
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)
// OpenAI HTTP upstream protocol strategy
viper.SetDefault("gateway.openai_http2.enabled", true)
viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true)
@@ -2663,14 +2666,16 @@ func (c *Config) Validate() error {
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 {
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 ||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
}
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT +
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
if weightSum <= 0 {
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
}
+20
View File
@@ -167,6 +167,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if cfg.Gateway.OpenAIWS.PayloadLogSampleRate != 0.2 {
t.Fatalf("Gateway.OpenAIWS.PayloadLogSampleRate = %v, want 0.2", cfg.Gateway.OpenAIWS.PayloadLogSampleRate)
}
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 {
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom)
}
if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
}
@@ -1717,6 +1720,11 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights quota_headroom 不能为负数",
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 },
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
},
{
name: "scheduler_score_weights 不能全为 0",
mutate: func(c *Config) {
@@ -1756,6 +1764,18 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
require.Contains(t, err.Error(), tc.wantErr)
})
}
t.Run("quota_headroom 可作为唯一有效调度权重", func(t *testing.T) {
cfg := buildValid(t)
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = 0.1
require.NoError(t, cfg.Validate())
})
}
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
@@ -29,6 +29,12 @@ const (
openAIAdvancedSchedulerSettingDBTimeout = 2 * time.Second
)
const (
openAIQuotaHeadroomNeutralFactor = 0.5
openAIQuotaHeadroomSecondaryLowRemain = 0.10
openAIQuotaHeadroomSnapshotStaleAfter = 8 * time.Hour
)
type cachedOpenAIAdvancedSchedulerSetting struct {
enabled bool
expiresAt int64
@@ -799,13 +805,18 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
}
}
}
quotaHeadroomFactor := 0.0
if weights.QuotaHeadroom > 0 {
quotaHeadroomFactor = openAIQuotaHeadroomFactor(item.account, now)
}
item.score = weights.Priority*priorityFactor +
weights.Load*loadFactor +
weights.Queue*queueFactor +
weights.ErrorRate*errorFactor +
weights.TTFT*ttftFactor +
weights.Reset*resetFactor
weights.Reset*resetFactor +
weights.QuotaHeadroom*quotaHeadroomFactor
}
plan.candidates = candidates
@@ -1460,21 +1471,23 @@ func (s *OpenAIGatewayService) openAIStickyEscapeConfig() openAIStickyEscapeConf
func (s *OpenAIGatewayService) openAIWSSchedulerWeights() GatewayOpenAIWSSchedulerScoreWeightsView {
if s != nil && s.cfg != nil {
return GatewayOpenAIWSSchedulerScoreWeightsView{
Priority: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority,
Load: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load,
Queue: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue,
ErrorRate: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate,
TTFT: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT,
Reset: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset,
Priority: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority,
Load: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load,
Queue: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue,
ErrorRate: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate,
TTFT: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT,
Reset: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset,
QuotaHeadroom: s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom,
}
}
return GatewayOpenAIWSSchedulerScoreWeightsView{
Priority: 1.0,
Load: 1.0,
Queue: 0.7,
ErrorRate: 0.8,
TTFT: 0.5,
Reset: 0.0,
Priority: 1.0,
Load: 1.0,
Queue: 0.7,
ErrorRate: 0.8,
TTFT: 0.5,
Reset: 0.0,
QuotaHeadroom: 0.0,
}
}
@@ -1485,7 +1498,49 @@ type GatewayOpenAIWSSchedulerScoreWeightsView struct {
ErrorRate float64
TTFT float64
// Reset 倾向「会话窗口最早重置」的账号;0 表示关闭(默认)。
Reset float64
Reset float64
QuotaHeadroom float64
}
func openAIQuotaHeadroomFactor(account *Account, now time.Time) float64 {
if account == nil || len(account.Extra) == 0 || openAIQuotaHeadroomSnapshotStale(account.Extra, now) {
return openAIQuotaHeadroomNeutralFactor
}
primaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_primary_used_percent", "codex_7d_used_percent")
if !ok || openAIQuotaWindowResetAny(account.Extra, now, "primary", "7d") {
return openAIQuotaHeadroomNeutralFactor
}
factor := 1 - clamp01(primaryUsedPercent/100)
if secondaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_secondary_used_percent", "codex_5h_used_percent"); ok &&
!openAIQuotaWindowResetAny(account.Extra, now, "secondary", "5h") {
secondaryRemaining := 1 - clamp01(secondaryUsedPercent/100)
if secondaryRemaining < openAIQuotaHeadroomSecondaryLowRemain {
factor *= openAIQuotaHeadroomNeutralFactor
}
}
return factor
}
func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool {
updatedRaw, ok := extra["codex_usage_updated_at"]
if !ok {
return true
}
updatedAt, err := parseTime(fmt.Sprint(updatedRaw))
if err != nil {
return true
}
return now.Sub(updatedAt) >= openAIQuotaHeadroomSnapshotStaleAfter
}
func openAIQuotaWindowResetAny(extra map[string]any, now time.Time, windows ...string) bool {
for _, window := range windows {
if openAIQuotaWindowReset(extra, window, now) {
return true
}
}
return false
}
func clamp01(value float64) float64 {
@@ -11,12 +11,21 @@ import (
func openAIResetTestScheduler(reset float64) *defaultOpenAIAccountScheduler {
cfg := &config.Config{}
cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{
Priority: 1.0,
Load: 1.0,
Queue: 0.7,
ErrorRate: 0.8,
TTFT: 0.5,
Reset: reset,
Priority: 1.0,
Load: 1.0,
Queue: 0.7,
ErrorRate: 0.8,
TTFT: 0.5,
Reset: reset,
QuotaHeadroom: 0,
}
return &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: cfg}}
}
func openAIQuotaHeadroomTestScheduler(quotaHeadroom float64) *defaultOpenAIAccountScheduler {
cfg := &config.Config{}
cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{
QuotaHeadroom: quotaHeadroom,
}
return &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: cfg}}
}
@@ -75,3 +84,113 @@ func TestBuildOpenAIAccountLoadPlan_ResetWeightIgnoresNilWindow(t *testing.T) {
scores := openAIPlanScores(plan)
require.Greater(t, scores[2], scores[1], "拥有活跃窗口的账号得分高于无窗口账号")
}
func TestOpenAIQuotaHeadroomFactor_PrimaryUsedPercent(t *testing.T) {
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
account := &Account{
Extra: map[string]any{
"codex_primary_used_percent": 20.0,
"codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
}
require.InDelta(t, 0.8, openAIQuotaHeadroomFactor(account, now), 0.0001)
}
func TestOpenAIQuotaHeadroomFactor_PrimaryMissingIsNeutral(t *testing.T) {
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
account := &Account{
Extra: map[string]any{
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
}
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(account, now))
}
func TestOpenAIQuotaHeadroomFactor_PrimaryResetExpiredIsNeutral(t *testing.T) {
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
account := &Account{
Extra: map[string]any{
"codex_primary_used_percent": 20.0,
"codex_primary_reset_at": now.Add(-time.Minute).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
}
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(account, now))
}
func TestOpenAIQuotaHeadroomFactor_SecondaryLowHeadroomDiscountsPrimary(t *testing.T) {
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
account := &Account{
Extra: map[string]any{
"codex_primary_used_percent": 20.0,
"codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
"codex_secondary_used_percent": 95.0,
"codex_secondary_reset_at": now.Add(time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
}
require.InDelta(t, 0.4, openAIQuotaHeadroomFactor(account, now), 0.0001)
}
func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomPrefersHigher7dRemaining(t *testing.T) {
now := time.Now()
filtered := []*Account{
{
ID: 1,
Priority: 0,
Extra: map[string]any{
"codex_primary_used_percent": 80.0,
"codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
},
{
ID: 2,
Priority: 0,
Extra: map[string]any{
"codex_primary_used_percent": 20.0,
"codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
},
}
sched := openAIQuotaHeadroomTestScheduler(1.0)
plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
scores := openAIPlanScores(plan)
require.Greater(t, scores[2], scores[1], "7d 剩余额度更高的账号得分应更高")
}
func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomZeroNoEffect(t *testing.T) {
now := time.Now()
filtered := []*Account{
{
ID: 1,
Priority: 0,
Extra: map[string]any{
"codex_primary_used_percent": 80.0,
"codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
},
{
ID: 2,
Priority: 0,
Extra: map[string]any{
"codex_primary_used_percent": 20.0,
"codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
},
},
}
sched := openAIResetTestScheduler(0)
plan := sched.buildOpenAIAccountLoadPlan(OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
scores := openAIPlanScores(plan)
require.Equal(t, scores[1], scores[2], "quota_headroom 权重为 0 时不应影响打分")
}
+2
View File
@@ -323,6 +323,8 @@ gateway:
# use-it-or-lose-it:倾向「会话窗口最早重置」的账号,剩余重置时间越短得分越高。
# 0 表示关闭(默认,不改变原有行为);调大可让即将重置的账号被优先用尽。
reset: 0.0
# 倾向 7d 剩余额度更健康的账号;0 表示关闭(默认,不改变原有行为),小流量灰度可设为 0.3。
quota_headroom: 0.0
# OpenAI 高级调度器补充配置
openai_scheduler:
# 是否允许 session_hash sticky 在账号健康度恶化时临时逃逸;false 可一键回退旧行为