grok_upstream_failure.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "regexp"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "github.com/tidwall/gjson"
  11. )
  12. // Grok upstream failure classes used to decide temp-unschedulable cooldowns and
  13. // pre-commit account failover. Classification is body-first so free-usage and
  14. // empty-output wording still win when the proxy rewrites status codes.
  15. type GrokUpstreamFailureClass string
  16. const (
  17. GrokFailureNone GrokUpstreamFailureClass = ""
  18. GrokFailureFreeUsage GrokUpstreamFailureClass = "subscription:free-usage-exhausted"
  19. GrokFailureBilling GrokUpstreamFailureClass = "billing_quota"
  20. GrokFailureEmptyUpstream GrokUpstreamFailureClass = "empty_upstream"
  21. GrokFailureModelCapacity GrokUpstreamFailureClass = "model_capacity"
  22. GrokFailureRateLimit GrokUpstreamFailureClass = "rate_limit"
  23. GrokFailureAuth GrokUpstreamFailureClass = "auth_error"
  24. GrokFailureServer GrokUpstreamFailureClass = "server_error"
  25. // GrokFailureCompatibility represents a request-history/body-shape that is
  26. // incompatible with the selected account or upstream replay contract. It
  27. // is account-independent: fail over, but never quarantine the pool.
  28. GrokFailureCompatibility GrokUpstreamFailureClass = "compatibility_error"
  29. )
  30. // GrokUpstreamFailureDecision is a pure classification result. Callers map it
  31. // onto existing account state helpers (tempUnscheduleGrok / rateLimitGrok).
  32. // BlockModel is retained for observability; the current scheduler does not
  33. // implement per-model soft-blocks, so free-usage deliberately never sets it.
  34. type GrokUpstreamFailureDecision struct {
  35. Class GrokUpstreamFailureClass
  36. Model string
  37. Cooldown time.Duration
  38. ShouldCooldown bool
  39. // ShouldFailover recommends trying another account before writing a
  40. // terminal response (pre-commit only). Content-policy rejections are
  41. // handled separately and never reach this classifier for failover.
  42. ShouldFailover bool
  43. // BlockModel is true only for empty-output when a model id is known.
  44. // Free-usage never sets this: the account cools, not a single model.
  45. BlockModel bool
  46. Reason string
  47. TokensActual *int64
  48. TokensLimit *int64
  49. }
  50. var (
  51. reGrokTokenPair = regexp.MustCompile(`(?i)tokens?\s*(?:\(actual\s*/\s*limit\))?\s*[:=]?\s*(\d+)\s*/\s*(\d+)`)
  52. reGrokModelFor = regexp.MustCompile(`(?i)(?:for\s+model|model|模型)\s*[::]?\s*([a-z0-9][a-z0-9._-]{2,80})`)
  53. )
  54. // classifyGrokUpstreamFailure decides cooldown/failover from status + body.
  55. // Priority (body/code first, status second):
  56. // 1. free-usage exhausted → account cool, no model block, failover
  57. // 2. billing hard quota → longer cool, failover
  58. // 3. empty model output → short cool + optional model soft-block marker, failover
  59. // 4. model capacity → short cool, failover
  60. // 5. bare rate-limit / 429 without free-usage language → cool, failover
  61. // 6. bare 5xx → brief cool, failover
  62. // 7. validation / client errors without quota language → no cool
  63. //
  64. // Content-policy 403s must be filtered by the caller before invoking this.
  65. func classifyGrokUpstreamFailure(statusCode int, responseBody []byte, requestedModel string) GrokUpstreamFailureDecision {
  66. text, code, low := grokUpstreamErrorCorpus(statusCode, responseBody)
  67. model := extractGrokFailureModel(text, responseBody, requestedModel)
  68. actual, limit, hasTokens := parseGrokTokenPair(text)
  69. if !hasTokens {
  70. actual, limit, hasTokens = parseGrokTokenPair(string(responseBody))
  71. }
  72. // --- Free usage / rolling quota exhausted ---
  73. if isGrokFreeUsageExhaustedText(low) || isGrokFreeUsageCode(code) || isGrokFreeUsageCode(text) {
  74. d := GrokUpstreamFailureDecision{
  75. Class: GrokFailureFreeUsage,
  76. Model: model,
  77. Cooldown: grokFreeUsageCooldownDuration(low),
  78. ShouldCooldown: true,
  79. ShouldFailover: true,
  80. BlockModel: false,
  81. Reason: firstNonEmpty(text, code, "free usage exhausted"),
  82. }
  83. if hasTokens {
  84. a, b := actual, limit
  85. d.TokensActual = &a
  86. d.TokensLimit = &b
  87. }
  88. return d
  89. }
  90. // Billing / hard quota (not free-tier rolling).
  91. // Cooldown stays at 30m to match the existing Grok 402/spending-limit
  92. // handler (longer cools would change ops behavior without a settings knob).
  93. if isGrokBillingQuotaText(low) || statusCode == http.StatusPaymentRequired {
  94. reason := firstNonEmpty(text, "billing quota")
  95. if statusCode == http.StatusPaymentRequired && text == "" {
  96. reason = "payment required"
  97. }
  98. return GrokUpstreamFailureDecision{
  99. Class: GrokFailureBilling,
  100. Model: model,
  101. Cooldown: 30 * time.Minute,
  102. ShouldCooldown: true,
  103. ShouldFailover: true,
  104. BlockModel: model != "",
  105. Reason: reason,
  106. }
  107. }
  108. // Responses replay/compaction payloads can be rejected by one Grok
  109. // deployment while the same request is valid on another account. Treat
  110. // these precise decoder/content-shape failures as account compatibility
  111. // errors, rather than durable account health failures.
  112. if isGrokCompatibilityError(statusCode, low, code) {
  113. return GrokUpstreamFailureDecision{
  114. Class: GrokFailureCompatibility,
  115. Model: model,
  116. ShouldFailover: true,
  117. ShouldCooldown: false,
  118. Reason: firstNonEmpty(text, "grok response compatibility error"),
  119. }
  120. }
  121. // Empty HTTP 200 / empty model output (often rewritten to synthetic 502).
  122. if isGrokEmptyModelOutputText(low) || isGrokEmptyModelOutputCode(code) {
  123. return GrokUpstreamFailureDecision{
  124. Class: GrokFailureEmptyUpstream,
  125. Model: model,
  126. Cooldown: 4 * time.Minute,
  127. ShouldCooldown: true,
  128. ShouldFailover: true,
  129. BlockModel: model != "",
  130. Reason: firstNonEmpty(text, "empty model output"),
  131. }
  132. }
  133. // Model capacity / overloaded.
  134. if isGrokModelCapacityText(low) {
  135. return GrokUpstreamFailureDecision{
  136. Class: GrokFailureModelCapacity,
  137. Model: model,
  138. Cooldown: time.Minute,
  139. ShouldCooldown: true,
  140. ShouldFailover: true,
  141. BlockModel: false,
  142. Reason: firstNonEmpty(text, "model capacity"),
  143. }
  144. }
  145. // Rate limit without free-usage language.
  146. if statusCode == http.StatusTooManyRequests || isGrokRateLimitText(low) {
  147. return GrokUpstreamFailureDecision{
  148. Class: GrokFailureRateLimit,
  149. Model: model,
  150. Cooldown: 10 * time.Minute,
  151. ShouldCooldown: true,
  152. ShouldFailover: true,
  153. BlockModel: false,
  154. Reason: firstNonEmpty(text, "rate limit"),
  155. }
  156. }
  157. // Upstream 5xx — brief cool. Empty-output synthetic 502 already handled above.
  158. if statusCode >= 500 && statusCode <= 599 {
  159. return GrokUpstreamFailureDecision{
  160. Class: GrokFailureServer,
  161. Cooldown: 2 * time.Minute,
  162. ShouldCooldown: true,
  163. ShouldFailover: true,
  164. Reason: firstNonEmpty(text, "server error"),
  165. }
  166. }
  167. return GrokUpstreamFailureDecision{Reason: text}
  168. }
  169. func grokUpstreamErrorCorpus(statusCode int, responseBody []byte) (text, code, low string) {
  170. _ = statusCode // classifier already has the transport status; corpus is body-only
  171. raw := strings.TrimSpace(string(responseBody))
  172. // Strip "upstream status NNN: ..." prefixes so free-usage / quota language is visible.
  173. if _, unwrappedBody, ok := unwrapGrokUpstreamErrorText(raw); ok {
  174. raw = unwrappedBody
  175. }
  176. text = raw
  177. codeFromJSON, msgFromJSON := parseGrokUpstreamErrorJSON(raw)
  178. if msgFromJSON != "" {
  179. if text == "" || len(msgFromJSON) > len(text)/2 || looksLikeGrokQuotaMessage(msgFromJSON) {
  180. text = msgFromJSON
  181. }
  182. }
  183. // Prefer structured fields from the original body when present.
  184. if len(responseBody) > 0 {
  185. if m := strings.TrimSpace(firstNonEmpty(
  186. gjson.GetBytes(responseBody, "error.message").String(),
  187. gjson.GetBytes(responseBody, "message").String(),
  188. gjson.GetBytes(responseBody, "error").String(),
  189. )); m != "" && (text == "" || looksLikeGrokQuotaMessage(m)) {
  190. text = m
  191. }
  192. if c := strings.TrimSpace(firstNonEmpty(
  193. gjson.GetBytes(responseBody, "error.code").String(),
  194. gjson.GetBytes(responseBody, "code").String(),
  195. )); c != "" {
  196. codeFromJSON = c
  197. }
  198. }
  199. code = codeFromJSON
  200. low = strings.ToLower(strings.TrimSpace(text))
  201. if code != "" && !strings.Contains(low, strings.ToLower(code)) {
  202. low = strings.ToLower(code) + " " + low
  203. }
  204. return text, code, low
  205. }
  206. func unwrapGrokUpstreamErrorText(errText string) (status int, body string, ok bool) {
  207. text := strings.TrimSpace(errText)
  208. if text == "" {
  209. return 0, "", false
  210. }
  211. lower := strings.ToLower(text)
  212. for _, p := range []string{"upstream status ", "status "} {
  213. if !strings.HasPrefix(lower, p) {
  214. continue
  215. }
  216. rest := strings.TrimSpace(text[len(p):])
  217. i := 0
  218. for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' {
  219. status = status*10 + int(rest[i]-'0')
  220. i++
  221. }
  222. if status <= 0 || i == 0 {
  223. return 0, "", false
  224. }
  225. rest = strings.TrimSpace(rest[i:])
  226. if strings.HasPrefix(rest, ":") {
  227. rest = strings.TrimSpace(rest[1:])
  228. }
  229. return status, rest, true
  230. }
  231. return 0, "", false
  232. }
  233. func parseGrokUpstreamErrorJSON(errText string) (code, message string) {
  234. text := strings.TrimSpace(errText)
  235. if text == "" || text[0] != '{' {
  236. return "", ""
  237. }
  238. var payload map[string]any
  239. if json.Unmarshal([]byte(text), &payload) != nil {
  240. return "", ""
  241. }
  242. if v, ok := payload["code"].(string); ok {
  243. code = v
  244. }
  245. if v, ok := payload["message"].(string); ok {
  246. message = v
  247. }
  248. if errObj, ok := payload["error"].(map[string]any); ok {
  249. if v, ok := errObj["code"].(string); ok && code == "" {
  250. code = v
  251. }
  252. if v, ok := errObj["message"].(string); ok && message == "" {
  253. message = v
  254. }
  255. }
  256. if errStr, ok := payload["error"].(string); ok && message == "" {
  257. message = errStr
  258. }
  259. return strings.TrimSpace(code), strings.TrimSpace(message)
  260. }
  261. func looksLikeGrokQuotaMessage(s string) bool {
  262. low := strings.ToLower(s)
  263. return strings.Contains(low, "quota") ||
  264. strings.Contains(low, "usage") ||
  265. strings.Contains(low, "credit") ||
  266. strings.Contains(low, "额度") ||
  267. strings.Contains(low, "free")
  268. }
  269. func isGrokFreeUsageCode(code string) bool {
  270. c := strings.ToLower(strings.TrimSpace(code))
  271. if c == "" {
  272. return false
  273. }
  274. if strings.Contains(c, "subscription:free-usage-exhausted") ||
  275. strings.Contains(c, "free-usage-exhausted") ||
  276. strings.Contains(c, "free_usage_exhausted") ||
  277. strings.Contains(c, "usage-limit-exceeded") ||
  278. strings.Contains(c, "usage_limit_exceeded") {
  279. return true
  280. }
  281. return (strings.Contains(c, "free-usage") || strings.Contains(c, "free_usage")) &&
  282. (strings.Contains(c, "exhaust") || strings.Contains(c, "exceed") || strings.Contains(c, "limit"))
  283. }
  284. func isGrokFreeUsageExhaustedText(low string) bool {
  285. if low == "" {
  286. return false
  287. }
  288. if strings.Contains(low, "free-usage-exhausted") ||
  289. strings.Contains(low, "free_usage_exhausted") ||
  290. strings.Contains(low, "subscription:free-usage") ||
  291. strings.Contains(low, "usage-limit-exceeded") ||
  292. strings.Contains(low, "usage_limit_exceeded") ||
  293. strings.Contains(low, "free-tier-limit") ||
  294. strings.Contains(low, "free_tier_limit") {
  295. return true
  296. }
  297. if strings.Contains(low, "free usage") ||
  298. strings.Contains(low, "included free usage") ||
  299. strings.Contains(low, "used all the included free") ||
  300. strings.Contains(low, "you've used all the included free") ||
  301. strings.Contains(low, "you have used all the included free") ||
  302. strings.Contains(low, "free quota") ||
  303. strings.Contains(low, "no remaining free") ||
  304. strings.Contains(low, "out of free") ||
  305. strings.Contains(low, "usage resets over a rolling") ||
  306. (strings.Contains(low, "free tier") && (strings.Contains(low, "exhaust") || strings.Contains(low, "limit") || strings.Contains(low, "exceed"))) {
  307. return true
  308. }
  309. for _, p := range []string{
  310. "额度耗尽", "额度用完", "额度不足", "额度已用尽", "额度已耗尽",
  311. "免费额度", "免费用量", "用量用完", "用量耗尽", "用量超限", "用量已用尽",
  312. "配额耗尽", "配额已用尽", "配额不足", "配额超限", "配额用完",
  313. "没有额度", "没额度", "无额度", "可用额度不足", "模型额度",
  314. "临时额度", "额度已满", "额度超限", "额度达到上限",
  315. "模型额度用完", "模型额度耗尽", "账号额度用完", "账号额度耗尽",
  316. "额度不够", "没额度了", "额度没了", "用完额度", "耗尽额度",
  317. } {
  318. if strings.Contains(low, p) {
  319. return true
  320. }
  321. }
  322. if (strings.Contains(low, "quota") && (strings.Contains(low, "exhaust") || strings.Contains(low, "exceed") || strings.Contains(low, "limit"))) ||
  323. (strings.Contains(low, "usage") && (strings.Contains(low, "exhaust") || strings.Contains(low, "exceed")) && (strings.Contains(low, "limit") || strings.Contains(low, "free") || strings.Contains(low, "model"))) {
  324. if strings.Contains(low, "free") || strings.Contains(low, "rolling") ||
  325. strings.Contains(low, "24-hour") || strings.Contains(low, "24 hour") ||
  326. strings.Contains(low, "model") || strings.Contains(low, "subscription") ||
  327. strings.Contains(low, "included") || strings.Contains(low, "tokens") {
  328. return true
  329. }
  330. }
  331. if a, b, ok := parseGrokTokenPair(low); ok && b > 0 && a >= b {
  332. if strings.Contains(low, "free") || strings.Contains(low, "subscription") ||
  333. strings.Contains(low, "included") || strings.Contains(low, "model") ||
  334. strings.Contains(low, "usage") || strings.Contains(low, "quota") ||
  335. strings.Contains(low, "rolling") {
  336. return true
  337. }
  338. }
  339. return false
  340. }
  341. func isGrokBillingQuotaText(low string) bool {
  342. if low == "" {
  343. return false
  344. }
  345. if strings.Contains(low, "insufficient_quota") {
  346. return true
  347. }
  348. if strings.Contains(low, "billing") && strings.Contains(low, "quota") {
  349. return true
  350. }
  351. if strings.Contains(low, "payment") && (strings.Contains(low, "required") || strings.Contains(low, "fail")) {
  352. return true
  353. }
  354. if strings.Contains(low, "spending limit") || strings.Contains(low, "run out of credits") || strings.Contains(low, "out of credits") ||
  355. (strings.Contains(low, "need a grok subscription") || strings.Contains(low, "need grok subscription")) {
  356. return true
  357. }
  358. if strings.Contains(low, "余额不足") || strings.Contains(low, "欠费") || strings.Contains(low, "需要付费") {
  359. return true
  360. }
  361. return false
  362. }
  363. // grokRetryableOnSameAccount marks transient 429 classes for the shared
  364. // failover loop. Capacity and ordinary throttles are request/model pressure,
  365. // not evidence that the credential is invalid, so a bounded retry on the same
  366. // account is preferable before switching accounts. Free-usage and billing
  367. // exhaustion deliberately skip same-account retry and fail over immediately.
  368. func grokRetryableOnSameAccount(account *Account, statusCode int, responseBody []byte) bool {
  369. if account == nil || !account.IsGrok() {
  370. return false
  371. }
  372. decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
  373. switch decision.Class {
  374. case GrokFailureFreeUsage, GrokFailureBilling, GrokFailureCompatibility:
  375. // Quota/entitlement exhaustion is account state, not transient
  376. // pressure. Retrying the same account only repeats the failure.
  377. return false
  378. case GrokFailureModelCapacity:
  379. if statusCode == http.StatusTooManyRequests {
  380. return true
  381. }
  382. }
  383. return account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)
  384. }
  385. func grokSameAccountRetryMetadata(account *Account, statusCode int, responseBody []byte) (bool, time.Duration, time.Time, int) {
  386. if !grokRetryableOnSameAccount(account, statusCode, responseBody) {
  387. return false, 0, time.Time{}, 0
  388. }
  389. decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
  390. if decision.Class != GrokFailureModelCapacity {
  391. return true, 0, time.Time{}, 0
  392. }
  393. // The error is reconstructed after every upstream attempt, so a deadline
  394. // stored on the error cannot provide a request-wide window. Cap capacity
  395. // retries explicitly to one replay; this remains effective even when the
  396. // first attempt itself takes longer than the nominal 30-second window.
  397. return true, 500 * time.Millisecond, time.Now().Add(30 * time.Second), 1
  398. }
  399. // shouldMarkGrokTeamModelRateLimit controls the process-local sibling-account
  400. // overlay. Model-capacity responses are request pressure, not a team quota;
  401. // marking them would hide healthy sibling credentials while the bounded
  402. // same-account retry is still in progress. Ordinary 429s and free-usage
  403. // exhaustion retain the existing quota/team isolation behavior.
  404. func shouldMarkGrokTeamModelRateLimit(statusCode int, responseBody []byte) bool {
  405. decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
  406. if decision.Class == GrokFailureModelCapacity {
  407. return false
  408. }
  409. return statusCode == http.StatusTooManyRequests || decision.Class == GrokFailureFreeUsage
  410. }
  411. func isGrokCompatibilityError(statusCode int, low, code string) bool {
  412. if statusCode != http.StatusBadRequest && statusCode != http.StatusUnprocessableEntity {
  413. return false
  414. }
  415. combined := strings.ToLower(strings.TrimSpace(low + " " + code))
  416. // Compaction blobs are account/session-bound and frequently fail with 400
  417. // or 422 after a reconnect. Also cover xAI's JSON decoder shape errors.
  418. for _, phrase := range []string{
  419. "could not decode the compaction blob",
  420. "cannot decode the compaction blob",
  421. "decode the compaction blob",
  422. "ensure it is unmodified from the compact response",
  423. "compaction blob",
  424. } {
  425. if strings.Contains(combined, phrase) {
  426. return true
  427. }
  428. }
  429. for _, marker := range []string{
  430. "invalid_compaction",
  431. "compaction_decode_error",
  432. } {
  433. if strings.Contains(combined, marker) {
  434. return true
  435. }
  436. }
  437. return false
  438. }
  439. func isGrokModelCapacityText(low string) bool {
  440. return strings.Contains(low, "capacity") ||
  441. strings.Contains(low, "overloaded") ||
  442. strings.Contains(low, "server_busy") ||
  443. strings.Contains(low, "too many concurrent") ||
  444. strings.Contains(low, "engine_overloaded")
  445. }
  446. func isGrokRateLimitText(low string) bool {
  447. return strings.Contains(low, "rate limit") ||
  448. strings.Contains(low, "rate_limit") ||
  449. strings.Contains(low, "too many requests") ||
  450. strings.Contains(low, "请求过于频繁") ||
  451. strings.Contains(low, "速率限制")
  452. }
  453. func isGrokEmptyModelOutputText(low string) bool {
  454. if low == "" {
  455. return false
  456. }
  457. return strings.Contains(low, "empty model output") ||
  458. strings.Contains(low, "no content/tool_calls") ||
  459. strings.Contains(low, "no client-visible content") ||
  460. strings.Contains(low, "empty_upstream") ||
  461. strings.Contains(low, "empty upstream")
  462. }
  463. func isGrokEmptyModelOutputCode(code string) bool {
  464. c := strings.ToLower(strings.TrimSpace(code))
  465. if c == "" {
  466. return false
  467. }
  468. return c == "empty_upstream" ||
  469. c == "empty-model-output" ||
  470. c == "empty_model_output" ||
  471. strings.Contains(c, "empty_upstream") ||
  472. strings.Contains(c, "empty-model-output")
  473. }
  474. func grokFreeUsageCooldownDuration(low string) time.Duration {
  475. // "rolling 24-hour" describes the upstream usage window, not a cooldown
  476. // that starts when this proxy observes a 429. Without an upstream reset
  477. // timestamp we cannot know when the oldest usage exits that window, so use
  478. // a short probe interval and let a successful probe clear the block.
  479. return grokFreeUsageProbeCooldown
  480. }
  481. const grokFreeUsageProbeCooldown = 10 * time.Minute
  482. func parseGrokTokenPair(errText string) (actual, limit int64, ok bool) {
  483. m := reGrokTokenPair.FindStringSubmatch(errText)
  484. if len(m) != 3 {
  485. return 0, 0, false
  486. }
  487. a, errA := strconv.ParseInt(m[1], 10, 64)
  488. b, errB := strconv.ParseInt(m[2], 10, 64)
  489. if errA != nil || errB != nil {
  490. return 0, 0, false
  491. }
  492. return a, b, true
  493. }
  494. func extractGrokFailureModel(text string, responseBody []byte, fallback string) string {
  495. if m := reGrokModelFor.FindStringSubmatch(text); len(m) == 2 {
  496. return normalizeGrokFailureModelID(m[1])
  497. }
  498. if len(responseBody) > 0 {
  499. if m := strings.TrimSpace(firstNonEmpty(
  500. gjson.GetBytes(responseBody, "error.model").String(),
  501. gjson.GetBytes(responseBody, "model").String(),
  502. )); m != "" {
  503. return normalizeGrokFailureModelID(m)
  504. }
  505. }
  506. return normalizeGrokFailureModelID(fallback)
  507. }
  508. // normalizeGrokFailureModelID trims whitespace and trailing punctuation the
  509. // "for model X." extractors sometimes capture.
  510. func normalizeGrokFailureModelID(model string) string {
  511. model = strings.TrimSpace(model)
  512. model = strings.TrimRight(model, ".,;:!?")
  513. return strings.TrimSpace(model)
  514. }
  515. // applyGrokUpstreamFailureDecision maps a classification onto existing account
  516. // health helpers. Returns true when the decision fully handled the error path
  517. // (caller should not apply the status-code switch defaults again).
  518. func (s *OpenAIGatewayService) applyGrokUpstreamFailureDecision(
  519. ctx context.Context,
  520. account *Account,
  521. decision GrokUpstreamFailureDecision,
  522. ) bool {
  523. if s == nil || account == nil || !decision.ShouldCooldown || decision.Cooldown <= 0 {
  524. return false
  525. }
  526. // Keep reasons short and stable for ops UI / temp_unschedulable_reason.
  527. var reason string
  528. switch decision.Class {
  529. case GrokFailureFreeUsage:
  530. reason = "grok free usage exhausted"
  531. // Model-scoped free usage: soft-block only the named model so other
  532. // models on the same account remain pickable (grok2api ModelQuotaBlock).
  533. low := strings.ToLower(decision.Reason)
  534. if decision.Model != "" && isGrokModelSpecificFreeUsage(low, decision.Model) {
  535. until := time.Now().Add(decision.Cooldown)
  536. markGrokModelQuotaBlock(account.ID, decision.Model, until)
  537. // The upstream explicitly scoped exhaustion to this model, so an
  538. // account-wide cool would incorrectly take healthy sibling models out
  539. // of rotation.
  540. return true
  541. }
  542. case GrokFailureBilling:
  543. low := strings.ToLower(decision.Reason)
  544. if strings.Contains(low, "spending") || strings.Contains(low, "credits") {
  545. // Spending-limit/credit exhaustion is a billing-window condition. Keep
  546. // the account recoverable and let the normal rate-limit recovery clear it.
  547. s.rateLimitGrok(ctx, account, grokSpendingLimitResetAt(account, time.Now()))
  548. return true
  549. }
  550. // Keep the historical 402/payment reason for ops UI + regression tests.
  551. reason = "grok payment required"
  552. case GrokFailureEmptyUpstream:
  553. reason = "grok empty model output"
  554. case GrokFailureModelCapacity:
  555. // Capacity is scoped to the requested model. Never persist an account-wide
  556. // unschedulable state for this transient class; the failover loop performs
  557. // a bounded same-account retry before selecting another account.
  558. _ = persistGrokTransientModelCooldown(account, decision)
  559. return true
  560. case GrokFailureRateLimit:
  561. // Pure 429 without free-usage language keeps the existing rate-limit
  562. // snapshot path (Retry-After / quota headers). Body-only rate-limit
  563. // phrasing still cools here via ShouldCooldown from the classifier, but
  564. // the handler only invokes this for non-RateLimit classes.
  565. return false
  566. case GrokFailureServer:
  567. reason = "grok upstream temporary error"
  568. case GrokFailureCompatibility:
  569. // Deliberately no account mutation. The caller uses ShouldFailover to
  570. // retry another account; cooling a pool for a request-shape mismatch
  571. // would remove healthy accounts.
  572. return true
  573. default:
  574. return false
  575. }
  576. s.tempUnscheduleGrok(ctx, account, decision.Cooldown, reason)
  577. return true
  578. }