openai_account_scheduler_reset_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. package service
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func openAIResetTestScheduler(reset float64) *defaultOpenAIAccountScheduler {
  10. cfg := &config.Config{}
  11. cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{
  12. Priority: 1.0,
  13. Load: 1.0,
  14. Queue: 0.7,
  15. ErrorRate: 0.8,
  16. TTFT: 0.5,
  17. Reset: reset,
  18. QuotaHeadroom: 0,
  19. }
  20. return &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: cfg}}
  21. }
  22. func openAIQuotaHeadroomTestScheduler(quotaHeadroom float64) *defaultOpenAIAccountScheduler {
  23. cfg := &config.Config{}
  24. cfg.Gateway.OpenAIWS.SchedulerScoreWeights = config.GatewayOpenAIWSSchedulerScoreWeights{
  25. QuotaHeadroom: quotaHeadroom,
  26. }
  27. return &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{cfg: cfg}}
  28. }
  29. func openAIPlanScores(plan openAIAccountLoadPlan) map[int64]float64 {
  30. scores := make(map[int64]float64, len(plan.candidates))
  31. for _, c := range plan.candidates {
  32. scores[c.account.ID] = c.score
  33. }
  34. return scores
  35. }
  36. // Reset 权重 > 0 时,会话窗口最早重置的账号应获得更高分。
  37. func TestBuildOpenAIAccountLoadPlan_ResetWeightPrefersSoonestReset(t *testing.T) {
  38. now := time.Now()
  39. soon := now.Add(1 * time.Hour)
  40. later := now.Add(20 * time.Hour)
  41. filtered := []*Account{
  42. {ID: 1, Priority: 0, SessionWindowEnd: &later},
  43. {ID: 2, Priority: 0, SessionWindowEnd: &soon},
  44. }
  45. sched := openAIResetTestScheduler(5.0)
  46. plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
  47. scores := openAIPlanScores(plan)
  48. require.Greater(t, scores[2], scores[1], "重置时间最早的账号(ID=2)得分更高")
  49. }
  50. // Reset 权重为 0(默认)时,窗口重置时间不应影响打分,保持原有行为。
  51. func TestBuildOpenAIAccountLoadPlan_ResetWeightZeroNoEffect(t *testing.T) {
  52. now := time.Now()
  53. soon := now.Add(1 * time.Hour)
  54. later := now.Add(20 * time.Hour)
  55. filtered := []*Account{
  56. {ID: 1, Priority: 0, SessionWindowEnd: &later},
  57. {ID: 2, Priority: 0, SessionWindowEnd: &soon},
  58. }
  59. sched := openAIResetTestScheduler(0.0)
  60. plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
  61. scores := openAIPlanScores(plan)
  62. require.Equal(t, scores[1], scores[2], "Reset 权重为 0 时两账号得分相同")
  63. }
  64. // 无活跃窗口的账号 reset 因子为 0,应低于拥有未来窗口的账号。
  65. func TestBuildOpenAIAccountLoadPlan_ResetWeightIgnoresNilWindow(t *testing.T) {
  66. now := time.Now()
  67. soon := now.Add(2 * time.Hour)
  68. filtered := []*Account{
  69. {ID: 1, Priority: 0, SessionWindowEnd: nil},
  70. {ID: 2, Priority: 0, SessionWindowEnd: &soon},
  71. }
  72. sched := openAIResetTestScheduler(5.0)
  73. plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
  74. scores := openAIPlanScores(plan)
  75. require.Greater(t, scores[2], scores[1], "拥有活跃窗口的账号得分高于无窗口账号")
  76. }
  77. func TestOpenAIQuotaHeadroomFactor_PrimaryUsedPercent(t *testing.T) {
  78. now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
  79. account := &Account{
  80. Extra: map[string]any{
  81. "codex_primary_used_percent": 20.0,
  82. "codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  83. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  84. },
  85. }
  86. require.InDelta(t, 0.8, openAIQuotaHeadroomFactor(account, now), 0.0001)
  87. }
  88. func TestOpenAIQuotaHeadroomFactor_PrimaryMissingIsNeutral(t *testing.T) {
  89. now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
  90. account := &Account{
  91. Extra: map[string]any{
  92. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  93. },
  94. }
  95. require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(account, now))
  96. }
  97. func TestOpenAIQuotaHeadroomFactor_PrimaryResetExpiredIsNeutral(t *testing.T) {
  98. now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
  99. account := &Account{
  100. Extra: map[string]any{
  101. "codex_primary_used_percent": 20.0,
  102. "codex_primary_reset_at": now.Add(-time.Minute).Format(time.RFC3339),
  103. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  104. },
  105. }
  106. require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(account, now))
  107. }
  108. func TestOpenAIQuotaHeadroomFactor_SecondaryLowHeadroomDiscountsPrimary(t *testing.T) {
  109. now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
  110. account := &Account{
  111. Extra: map[string]any{
  112. "codex_primary_used_percent": 20.0,
  113. "codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  114. "codex_secondary_used_percent": 95.0,
  115. "codex_secondary_reset_at": now.Add(time.Hour).Format(time.RFC3339),
  116. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  117. },
  118. }
  119. require.InDelta(t, 0.4, openAIQuotaHeadroomFactor(account, now), 0.0001)
  120. }
  121. func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomPrefersHigher7dRemaining(t *testing.T) {
  122. now := time.Now()
  123. filtered := []*Account{
  124. {
  125. ID: 1,
  126. Priority: 0,
  127. Extra: map[string]any{
  128. "codex_primary_used_percent": 80.0,
  129. "codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  130. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  131. },
  132. },
  133. {
  134. ID: 2,
  135. Priority: 0,
  136. Extra: map[string]any{
  137. "codex_primary_used_percent": 20.0,
  138. "codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  139. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  140. },
  141. },
  142. }
  143. sched := openAIQuotaHeadroomTestScheduler(1.0)
  144. plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
  145. scores := openAIPlanScores(plan)
  146. require.Greater(t, scores[2], scores[1], "7d 剩余额度更高的账号得分应更高")
  147. }
  148. func TestBuildOpenAIAccountLoadPlan_QuotaHeadroomZeroNoEffect(t *testing.T) {
  149. now := time.Now()
  150. filtered := []*Account{
  151. {
  152. ID: 1,
  153. Priority: 0,
  154. Extra: map[string]any{
  155. "codex_primary_used_percent": 80.0,
  156. "codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  157. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  158. },
  159. },
  160. {
  161. ID: 2,
  162. Priority: 0,
  163. Extra: map[string]any{
  164. "codex_primary_used_percent": 20.0,
  165. "codex_primary_reset_at": now.Add(24 * time.Hour).Format(time.RFC3339),
  166. "codex_usage_updated_at": now.Add(-time.Minute).Format(time.RFC3339),
  167. },
  168. },
  169. }
  170. sched := openAIResetTestScheduler(0)
  171. plan := sched.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, filtered, map[int64]*AccountLoadInfo{})
  172. scores := openAIPlanScores(plan)
  173. require.Equal(t, scores[1], scores[2], "quota_headroom 权重为 0 时不应影响打分")
  174. }