openai_account_model_transient_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. package service
  2. import (
  3. "fmt"
  4. "strings"
  5. "sync"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestOpenAIModelTransient_FirstFailureDoesNotCreateLongBlock(t *testing.T) {
  12. state := newOpenAIAccountModelTransientState(128)
  13. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  14. decision := state.recordFailure(35, "gpt-5.5", now)
  15. assert.Equal(t, 1, decision.FailureStreak)
  16. assert.Zero(t, decision.Cooldown)
  17. assert.False(t, state.isBlocked(35, "gpt-5.5", now))
  18. }
  19. func TestOpenAIModelTransient_SecondFailureCreatesShortModelBlock(t *testing.T) {
  20. state := newOpenAIAccountModelTransientState(128)
  21. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  22. state.recordFailure(35, "gpt-5.5", now)
  23. decision := state.recordFailure(35, "gpt-5.5", now.Add(time.Second))
  24. assert.Equal(t, 2, decision.FailureStreak)
  25. assert.Equal(t, openAIModelTransientShortCooldown, decision.Cooldown)
  26. assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
  27. assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(openAIModelTransientShortCooldown+2*time.Second)))
  28. }
  29. func TestOpenAIModelTransient_ThirdFailureCreatesFortyFiveSecondModelBlock(t *testing.T) {
  30. state := newOpenAIAccountModelTransientState(128)
  31. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  32. state.recordFailure(35, "gpt-5.5", now)
  33. state.recordFailure(35, "gpt-5.5", now.Add(time.Second))
  34. decision := state.recordFailure(35, "gpt-5.5", now.Add(2*time.Second))
  35. assert.Equal(t, 3, decision.FailureStreak)
  36. assert.Equal(t, 45*time.Second, decision.Cooldown)
  37. assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(40*time.Second)))
  38. assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(48*time.Second)))
  39. }
  40. func TestOpenAIModelTransient_BlockIsIsolatedByModel(t *testing.T) {
  41. state := newOpenAIAccountModelTransientState(128)
  42. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  43. state.recordFailure(35, "gpt-5.6-terra", now)
  44. state.recordFailure(35, "GPT-5.6-TERRA", now.Add(time.Second))
  45. assert.True(t, state.isBlocked(35, "gpt-5.6-terra", now.Add(2*time.Second)))
  46. assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
  47. assert.False(t, state.isBlocked(47, "gpt-5.6-terra", now.Add(2*time.Second)))
  48. }
  49. func TestOpenAIModelTransient_SuccessClearsStreakAndBlock(t *testing.T) {
  50. state := newOpenAIAccountModelTransientState(128)
  51. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  52. state.recordFailure(35, "gpt-5.5", now)
  53. state.recordFailure(35, "gpt-5.5", now.Add(time.Second))
  54. require.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
  55. state.recordSuccess(35, "gpt-5.5")
  56. assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(2*time.Second)))
  57. decision := state.recordFailure(35, "gpt-5.5", now.Add(3*time.Second))
  58. assert.Equal(t, 1, decision.FailureStreak)
  59. assert.Zero(t, decision.Cooldown)
  60. }
  61. func TestOpenAIModelTransient_StaleStreakExpires(t *testing.T) {
  62. state := newOpenAIAccountModelTransientState(128)
  63. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  64. state.recordFailure(35, "gpt-5.5", now)
  65. decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientStreakTTL+time.Second))
  66. assert.Equal(t, 1, decision.FailureStreak)
  67. assert.Zero(t, decision.Cooldown)
  68. }
  69. // A streak must not depend on how often the gateway is called. Sparse traffic
  70. // used to reset the streak between every request, so a broken account+model was
  71. // never cooled down and each request paid a failed attempt plus a failover.
  72. func TestOpenAIModelTransient_StreakSurvivesSparseTraffic(t *testing.T) {
  73. state := newOpenAIAccountModelTransientState(128)
  74. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  75. gap := 5 * time.Minute
  76. require.Greater(t, gap, openAIModelTransientLongCooldown,
  77. "the gap must exceed every cooldown, otherwise this passes for the wrong reason")
  78. first := state.recordFailure(35, "gpt-5.5", now)
  79. second := state.recordFailure(35, "gpt-5.5", now.Add(gap))
  80. third := state.recordFailure(35, "gpt-5.5", now.Add(2*gap))
  81. assert.Equal(t, 1, first.FailureStreak)
  82. assert.Zero(t, first.Cooldown)
  83. assert.Equal(t, 2, second.FailureStreak)
  84. assert.Equal(t, openAIModelTransientShortCooldown, second.Cooldown)
  85. assert.Equal(t, 3, third.FailureStreak)
  86. assert.Equal(t, openAIModelTransientLongCooldown, third.Cooldown)
  87. assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*gap+time.Second)))
  88. }
  89. // A success between two sparse failures still clears the streak, so an account
  90. // that intermittently works is not pushed into the long cooldown.
  91. func TestOpenAIModelTransient_SuccessResetsStreakAcrossSparseTraffic(t *testing.T) {
  92. state := newOpenAIAccountModelTransientState(128)
  93. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  94. gap := 5 * time.Minute
  95. state.recordFailure(35, "gpt-5.5", now)
  96. state.recordSuccess(35, "gpt-5.5")
  97. decision := state.recordFailure(35, "gpt-5.5", now.Add(gap))
  98. assert.Equal(t, 1, decision.FailureStreak)
  99. assert.Zero(t, decision.Cooldown)
  100. assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(gap+time.Second)))
  101. }
  102. func TestOpenAIModelTransient_IgnoresInvalidKeys(t *testing.T) {
  103. state := newOpenAIAccountModelTransientState(128)
  104. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  105. assert.Zero(t, state.recordFailure(0, "gpt-5.5", now).FailureStreak)
  106. assert.Zero(t, state.recordFailure(35, " ", now).FailureStreak)
  107. assert.False(t, state.isBlocked(0, "gpt-5.5", now))
  108. assert.False(t, state.isBlocked(35, "", now))
  109. assert.Equal(t, 0, state.size())
  110. }
  111. func TestOpenAIModelTransient_IgnoresOversizedModelKey(t *testing.T) {
  112. state := newOpenAIAccountModelTransientState(128)
  113. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  114. model := strings.Repeat("m", openAIModelTransientMaxModelBytes+1)
  115. decision := state.recordFailure(35, model, now)
  116. assert.Zero(t, decision.FailureStreak)
  117. assert.False(t, state.isBlocked(35, model, now))
  118. assert.Equal(t, 0, state.size())
  119. }
  120. func TestOpenAIModelTransient_StateIsBoundedAndConcurrencySafe(t *testing.T) {
  121. const maxEntries = 16
  122. state := newOpenAIAccountModelTransientState(maxEntries)
  123. now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
  124. var wg sync.WaitGroup
  125. for i := 0; i < 128; i++ {
  126. wg.Add(1)
  127. go func(i int) {
  128. defer wg.Done()
  129. model := fmt.Sprintf("gpt-test-%d", i)
  130. state.recordFailure(int64(i+1), model, now.Add(time.Duration(i)*time.Millisecond))
  131. _ = state.isBlocked(int64(i+1), model, now.Add(time.Second))
  132. }(i)
  133. }
  134. wg.Wait()
  135. assert.LessOrEqual(t, state.size(), maxEntries)
  136. }