mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #3441 from deqiying/feature/openai-quota-headroom-scheduler
新增 OpenAI 剩余额度调度权重
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 时不应影响打分")
|
||||
}
|
||||
|
||||
@@ -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 可一键回退旧行为
|
||||
|
||||
Reference in New Issue
Block a user