no_account_error.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. package handler
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "regexp"
  7. "strconv"
  8. "strings"
  9. "github.com/gin-gonic/gin"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. )
  12. // noAccountErrorClassification describes the HTTP response to emit when
  13. // account selection failed with ErrNoAvailableAccounts. Handlers obtain it
  14. // via classifyNoAccountError and choose between:
  15. //
  16. // - 404 model_not_found — the group has accounts, but none of them are
  17. // configured to serve the requested model (config / typo / unsupported
  18. // model). Returning 503 here misleads operators and trips reverse-proxy
  19. // health checks; 404 lets the client surface the real problem.
  20. //
  21. // - 503 api_error — accounts that could serve the model exist but are
  22. // temporarily exhausted (rate limit, quota auto-pause, runtime block) OR
  23. // the group has no accounts at all. Both stay on 503 because retrying
  24. // after a backoff can plausibly succeed (or, in the empty-pool case, the
  25. // operator may be in the middle of adding accounts).
  26. type noAccountErrorClassification struct {
  27. Status int
  28. ErrType string
  29. Message string
  30. ModelNotFound bool // true when this is a 404 model_not_found classification
  31. }
  32. var selectionModelRateLimitedPattern = regexp.MustCompile(`(?:model_rate_limited|rate_limited)=(\d+)`)
  33. // classifySelectionFailureError preserves the scheduler's compact reason when
  34. // every model-capable account is temporarily rate limited.
  35. func classifySelectionFailureError(err error, fallback noAccountErrorClassification) noAccountErrorClassification {
  36. if err == nil {
  37. return fallback
  38. }
  39. // A 404 model_not_found fallback is authoritative and must not be downgraded
  40. // to a rate-limit verdict. classifyNoAccountError only reaches it through
  41. // DiagnoseModelAvailabilityForPlatform, a dedicated database query over
  42. // persistent eligibility (active + schedulable + model_mapping) that already
  43. // established no account in the group can serve this model at all. A transient
  44. // per-model cooldown on one of the remaining candidates does not make "all
  45. // available accounts are rate-limited" true.
  46. //
  47. // Reporting 429 here is actively harmful: retrying can never succeed, and
  48. // clients that treat 429 as a rate limit retry hard and swallow the body
  49. // (Codex surfaces only "exceeded retry limit, last status: 429"), losing the
  50. // one message that names the real problem. It also flips the ops attribution
  51. // from a local model-configuration issue to routing capacity, because call
  52. // sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound.
  53. if fallback.ModelNotFound {
  54. return fallback
  55. }
  56. match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
  57. if len(match) != 2 {
  58. return fallback
  59. }
  60. count, parseErr := strconv.Atoi(match[1])
  61. if parseErr != nil || count <= 0 {
  62. return fallback
  63. }
  64. return noAccountErrorClassification{
  65. Status: http.StatusTooManyRequests,
  66. ErrType: "rate_limit_error",
  67. Message: "All available accounts are currently rate-limited. Please retry later.",
  68. }
  69. }
  70. // classifyNoAccountError decides between 404 model_not_found and 503
  71. // api_error for "no available accounts" failures.
  72. //
  73. // The classifier intentionally does not consume the original error: the
  74. // selection layer never tells us *why* the pool came up empty (rate-limited
  75. // vs. unsupported model are both wrapped as ErrNoAvailableAccounts). Instead
  76. // we re-check pool composition through DiagnoseModelAvailabilityForPlatform.
  77. // Its dedicated database query considers only persistent eligibility
  78. // (active status + schedulable setting) and model_mapping, bypassing scheduler
  79. // snapshots and transient filters. That guarantees a 404 is only returned
  80. // when persistent account/group/model configuration must change before the
  81. // request can succeed.
  82. //
  83. // routingModel is the model name that account selection actually compared
  84. // against (i.e. after group-level dispatch mapping). displayModel is the
  85. // raw model the caller asked for; it is used only in the user-facing error
  86. // message so that internal mapping details don't leak. Most callers pass
  87. // the same value for both.
  88. //
  89. // platform is the platform the request was routed to (use
  90. // service.PlatformOpenAI / PlatformAnthropic / PlatformGemini). It is
  91. // required because Anthropic/Gemini routes additionally surface
  92. // mixed-scheduled Antigravity accounts; passing the wrong platform would
  93. // flip a legitimate 503 to a misleading 404 (or vice versa).
  94. func classifyNoAccountError(
  95. ctx context.Context,
  96. diag service.ModelAvailabilityDiagnoser,
  97. apiKey *service.APIKey,
  98. routingModel string,
  99. displayModel string,
  100. platform string,
  101. ) noAccountErrorClassification {
  102. fallback := noAccountErrorClassification{
  103. Status: http.StatusServiceUnavailable,
  104. ErrType: "api_error",
  105. Message: "Service temporarily unavailable",
  106. }
  107. routingModel = strings.TrimSpace(routingModel)
  108. displayModel = strings.TrimSpace(displayModel)
  109. if displayModel == "" {
  110. displayModel = routingModel
  111. }
  112. if diag == nil || apiKey == nil || apiKey.GroupID == nil || routingModel == "" {
  113. return fallback
  114. }
  115. result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
  116. if result.HasAccountsInPool && !result.HasModelSupport {
  117. return noAccountErrorClassification{
  118. Status: http.StatusNotFound,
  119. ErrType: "model_not_found",
  120. Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
  121. ModelNotFound: true,
  122. }
  123. }
  124. return fallback
  125. }
  126. // classifyNoAccountErrorFromGin is a thin wrapper that forwards the gin
  127. // context's underlying request context. Most call sites already have a
  128. // *gin.Context handy, so this keeps the call sites uncluttered.
  129. func classifyNoAccountErrorFromGin(
  130. c *gin.Context,
  131. diag service.ModelAvailabilityDiagnoser,
  132. apiKey *service.APIKey,
  133. routingModel string,
  134. displayModel string,
  135. platform string,
  136. ) noAccountErrorClassification {
  137. ctx := context.Background()
  138. if c != nil && c.Request != nil {
  139. ctx = c.Request.Context()
  140. }
  141. classification := classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
  142. if classification.ModelNotFound {
  143. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  144. }
  145. return classification
  146. }
  147. func classifyOpenAICompatibleNoAccountErrorFromGin(
  148. c *gin.Context,
  149. diag service.ModelAvailabilityDiagnoser,
  150. apiKey *service.APIKey,
  151. routingModel string,
  152. displayModel string,
  153. ) noAccountErrorClassification {
  154. ctx := context.Background()
  155. if c != nil && c.Request != nil {
  156. ctx = c.Request.Context()
  157. }
  158. return classifyNoAccountErrorFromGin(
  159. c,
  160. diag,
  161. apiKey,
  162. routingModel,
  163. displayModel,
  164. openAICompatibleRequestPlatform(ctx, apiKey),
  165. )
  166. }
  167. func openAICompatibleSelectionErrorForLog(err error, platform string) error {
  168. if err == nil || platform != service.PlatformGrok {
  169. return err
  170. }
  171. message := strings.ReplaceAll(err.Error(), "OpenAI accounts", "Grok accounts")
  172. if message == err.Error() {
  173. return err
  174. }
  175. return fmt.Errorf("%s", message)
  176. }