openai_account_model_transient.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. package service
  2. import (
  3. "strings"
  4. "sync"
  5. "time"
  6. )
  7. const (
  8. // openAIModelTransientStreakTTL bounds how long a failure streak survives
  9. // without a new failure. It exists only so the map does not keep state for
  10. // account+model pairs that stopped being used; a streak is otherwise reset
  11. // by recordSuccess alone.
  12. //
  13. // It must stay well above the cooldowns. Resetting the streak on a short
  14. // wall-clock window makes the breaker's sensitivity depend on request rate:
  15. // a gateway called less often than the window never reaches streak 2, so a
  16. // broken upstream is never cooled down and every request pays a failed
  17. // attempt plus a failover before reaching a healthy account. Low-traffic
  18. // deployments were hit hardest, which is the opposite of what a breaker
  19. // should do.
  20. openAIModelTransientStreakTTL = 30 * time.Minute
  21. openAIModelTransientShortCooldown = 10 * time.Second
  22. openAIModelTransientLongCooldown = 45 * time.Second
  23. openAIModelTransientDefaultMax = 4096
  24. openAIModelTransientMaxModelBytes = 512
  25. )
  26. type openAIAccountModelKey struct {
  27. AccountID int64
  28. Model string
  29. }
  30. type openAIAccountModelTransientEntry struct {
  31. failureStreak int
  32. lastFailure time.Time
  33. blockUntil time.Time
  34. lastTouched time.Time
  35. }
  36. type openAIAccountModelTransientDecision struct {
  37. FailureStreak int
  38. Cooldown time.Duration
  39. BlockUntil time.Time
  40. }
  41. type openAIAccountModelTransientState struct {
  42. mu sync.Mutex
  43. entries map[openAIAccountModelKey]openAIAccountModelTransientEntry
  44. maxEntries int
  45. }
  46. func newOpenAIAccountModelTransientState(maxEntries int) *openAIAccountModelTransientState {
  47. if maxEntries <= 0 {
  48. maxEntries = openAIModelTransientDefaultMax
  49. }
  50. return &openAIAccountModelTransientState{
  51. entries: make(map[openAIAccountModelKey]openAIAccountModelTransientEntry),
  52. maxEntries: maxEntries,
  53. }
  54. }
  55. func normalizeOpenAIAccountModelTransientModel(model string) string {
  56. model = strings.TrimSpace(model)
  57. if len(model) > openAIModelTransientMaxModelBytes {
  58. return ""
  59. }
  60. return strings.ToLower(model)
  61. }
  62. func openAIAccountModelTransientKey(accountID int64, model string) (openAIAccountModelKey, bool) {
  63. model = normalizeOpenAIAccountModelTransientModel(model)
  64. if accountID <= 0 || model == "" {
  65. return openAIAccountModelKey{}, false
  66. }
  67. return openAIAccountModelKey{AccountID: accountID, Model: model}, true
  68. }
  69. func (s *openAIAccountModelTransientState) recordFailure(accountID int64, model string, now time.Time) openAIAccountModelTransientDecision {
  70. key, ok := openAIAccountModelTransientKey(accountID, model)
  71. if s == nil || !ok {
  72. return openAIAccountModelTransientDecision{}
  73. }
  74. if now.IsZero() {
  75. now = time.Now()
  76. }
  77. s.mu.Lock()
  78. defer s.mu.Unlock()
  79. if s.entries == nil {
  80. s.entries = make(map[openAIAccountModelKey]openAIAccountModelTransientEntry)
  81. }
  82. if s.maxEntries <= 0 {
  83. s.maxEntries = openAIModelTransientDefaultMax
  84. }
  85. entry, exists := s.entries[key]
  86. if !exists {
  87. s.evictOldestLocked()
  88. }
  89. // The streak is cleared by recordSuccess. Only drop it here when the entry
  90. // is stale beyond the TTL, or when the clock moved backwards.
  91. if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientStreakTTL || now.Before(entry.lastFailure) {
  92. entry.failureStreak = 0
  93. entry.blockUntil = time.Time{}
  94. }
  95. entry.failureStreak++
  96. entry.lastFailure = now
  97. entry.lastTouched = now
  98. cooldown := time.Duration(0)
  99. switch {
  100. case entry.failureStreak >= 3:
  101. cooldown = openAIModelTransientLongCooldown
  102. case entry.failureStreak == 2:
  103. cooldown = openAIModelTransientShortCooldown
  104. }
  105. if cooldown > 0 {
  106. entry.blockUntil = now.Add(cooldown)
  107. } else {
  108. entry.blockUntil = time.Time{}
  109. }
  110. s.entries[key] = entry
  111. return openAIAccountModelTransientDecision{
  112. FailureStreak: entry.failureStreak,
  113. Cooldown: cooldown,
  114. BlockUntil: entry.blockUntil,
  115. }
  116. }
  117. func (s *openAIAccountModelTransientState) recordSuccess(accountID int64, model string) {
  118. key, ok := openAIAccountModelTransientKey(accountID, model)
  119. if s == nil || !ok {
  120. return
  121. }
  122. s.mu.Lock()
  123. delete(s.entries, key)
  124. s.mu.Unlock()
  125. }
  126. func (s *openAIAccountModelTransientState) isBlocked(accountID int64, model string, now time.Time) bool {
  127. key, ok := openAIAccountModelTransientKey(accountID, model)
  128. if s == nil || !ok {
  129. return false
  130. }
  131. if now.IsZero() {
  132. now = time.Now()
  133. }
  134. s.mu.Lock()
  135. defer s.mu.Unlock()
  136. entry, exists := s.entries[key]
  137. if !exists {
  138. return false
  139. }
  140. if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientStreakTTL {
  141. delete(s.entries, key)
  142. return false
  143. }
  144. entry.lastTouched = now
  145. s.entries[key] = entry
  146. return !entry.blockUntil.IsZero() && now.Before(entry.blockUntil)
  147. }
  148. func (s *openAIAccountModelTransientState) size() int {
  149. if s == nil {
  150. return 0
  151. }
  152. s.mu.Lock()
  153. defer s.mu.Unlock()
  154. return len(s.entries)
  155. }
  156. func (s *openAIAccountModelTransientState) evictOldestLocked() {
  157. if len(s.entries) < s.maxEntries {
  158. return
  159. }
  160. var oldestKey openAIAccountModelKey
  161. var oldestTime time.Time
  162. found := false
  163. for key, entry := range s.entries {
  164. if !found || entry.lastTouched.Before(oldestTime) {
  165. oldestKey = key
  166. oldestTime = entry.lastTouched
  167. found = true
  168. }
  169. }
  170. if found {
  171. delete(s.entries, oldestKey)
  172. }
  173. }