grok_upstream_errors.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "strings"
  7. "time"
  8. "github.com/tidwall/gjson"
  9. )
  10. // isGrokContentPolicyRejection identifies request-scoped safety refusals from
  11. // xAI. These failures are caused by the prompt or media, so retrying another
  12. // OAuth account cannot change the outcome and would incorrectly drain a pool.
  13. // Keep this matcher deliberately narrow: account entitlement and suspension
  14. // messages may mention policy but must retain the normal account failover path.
  15. func isGrokContentPolicyRejection(statusCode int, responseBody []byte) bool {
  16. if statusCode != http.StatusForbidden || len(responseBody) == 0 {
  17. return false
  18. }
  19. if grokAccountAccessMessage(string(responseBody)) {
  20. return false
  21. }
  22. var payload any
  23. if json.Unmarshal(responseBody, &payload) == nil {
  24. if grokStructuredAccountAccessMarker(payload) {
  25. return false
  26. }
  27. if grokStructuredContentPolicyMarker(payload) {
  28. return true
  29. }
  30. }
  31. return grokContentPolicyMessage(string(responseBody))
  32. }
  33. func grokStructuredAccountAccessMarker(value any) bool {
  34. switch node := value.(type) {
  35. case map[string]any:
  36. for key, child := range node {
  37. normalizedKey := normalizeGrokErrorMarker(key)
  38. switch normalizedKey {
  39. case "code", "error_code", "type", "category", "reason":
  40. if marker, ok := child.(string); ok && isGrokAccountAccessCode(marker) {
  41. return true
  42. }
  43. }
  44. if grokStructuredAccountAccessMarker(child) {
  45. return true
  46. }
  47. }
  48. case []any:
  49. for _, child := range node {
  50. if grokStructuredAccountAccessMarker(child) {
  51. return true
  52. }
  53. }
  54. }
  55. return false
  56. }
  57. func grokStructuredContentPolicyMarker(value any) bool {
  58. switch node := value.(type) {
  59. case map[string]any:
  60. for key, child := range node {
  61. normalizedKey := normalizeGrokErrorMarker(key)
  62. switch normalizedKey {
  63. case "code", "error_code", "type", "category", "reason":
  64. if marker, ok := child.(string); ok && isGrokContentPolicyCode(marker) {
  65. return true
  66. }
  67. }
  68. if grokStructuredContentPolicyMarker(child) {
  69. return true
  70. }
  71. }
  72. case []any:
  73. for _, child := range node {
  74. if grokStructuredContentPolicyMarker(child) {
  75. return true
  76. }
  77. }
  78. }
  79. return false
  80. }
  81. func normalizeGrokErrorMarker(value string) string {
  82. value = strings.ToLower(strings.TrimSpace(value))
  83. value = strings.ReplaceAll(value, "-", "_")
  84. value = strings.ReplaceAll(value, " ", "_")
  85. return value
  86. }
  87. func isGrokContentPolicyCode(value string) bool {
  88. switch normalizeGrokErrorMarker(value) {
  89. case "content_filter",
  90. "content_policy",
  91. "content_policy_violation",
  92. "content_moderation",
  93. "cyber_policy",
  94. "new_sensitive":
  95. return true
  96. default:
  97. return false
  98. }
  99. }
  100. func isGrokAccountAccessCode(value string) bool {
  101. switch normalizeGrokErrorMarker(value) {
  102. case "account_suspended",
  103. "account_disabled",
  104. "user_suspended",
  105. "user_disabled",
  106. "subscription_required",
  107. "entitlement_required",
  108. "not_entitled",
  109. "plan_required":
  110. // permission-denied is omitted: xAI reuses it for both entitlement
  111. // refusals and request-scoped safety blocks, so the message decides.
  112. return true
  113. default:
  114. return false
  115. }
  116. }
  117. func grokAccountAccessMessage(value string) bool {
  118. lower := strings.ToLower(strings.TrimSpace(value))
  119. for _, phrase := range []string{
  120. "account suspended",
  121. "account has been suspended",
  122. "account disabled",
  123. "account has been disabled",
  124. "user suspended",
  125. "user has been suspended",
  126. "subscription required",
  127. "entitlement required",
  128. "not entitled",
  129. } {
  130. if strings.Contains(lower, phrase) {
  131. return true
  132. }
  133. }
  134. return false
  135. }
  136. func grokContentPolicyMessage(value string) bool {
  137. lower := strings.ToLower(strings.TrimSpace(value))
  138. if lower == "" {
  139. return false
  140. }
  141. // xAI's media safety responses use these exact phrases. They are specific
  142. // enough not to classify a generic account-policy or entitlement message.
  143. for _, phrase := range []string{
  144. "the moderation feature is not available",
  145. "image is sensitive",
  146. "text is sensitive",
  147. "prohibited content",
  148. "forbidden content",
  149. "content policy violation",
  150. "content policy rejection",
  151. "content policy rejected",
  152. "content moderation rejection",
  153. "content moderation rejected",
  154. "content moderation blocked",
  155. "request blocked by content moderation",
  156. "request rejected by content moderation",
  157. "request blocked by policy",
  158. "request rejected by policy",
  159. "request violates policy",
  160. "prompt violates content policy",
  161. "prompt violates policy",
  162. "input violates content policy",
  163. "input violates policy",
  164. "violates usage guidelines",
  165. } {
  166. if strings.Contains(lower, phrase) {
  167. return true
  168. }
  169. }
  170. return false
  171. }
  172. func grokContentPolicyClientMessage(responseBody []byte) string {
  173. message := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(responseBody)))
  174. if message == "" {
  175. return "Request blocked by upstream content policy"
  176. }
  177. return message
  178. }
  179. // shouldFailoverGrokUpstreamError is the body-aware counterpart of the
  180. // status-only failover helper. Grok content refusals must stay on the current
  181. // account and be returned to the caller instead of consuming the account pool.
  182. // Free-usage / empty-output / billing bodies also failover even when the HTTP
  183. // status alone would not (e.g. 400 with free-usage-exhausted).
  184. func (s *OpenAIGatewayService) shouldFailoverGrokUpstreamError(statusCode int, responseBody []byte) bool {
  185. if isGrokContentPolicyRejection(statusCode, responseBody) {
  186. return false
  187. }
  188. // A 422 emitted by xAI's ModelInput decoder is account/runtime compatibility,
  189. // not quota exhaustion. Another account may run a different upstream build,
  190. // so fail over without applying an account cooldown.
  191. if isGrokDecoderCompatibilityError(statusCode, responseBody) {
  192. return true
  193. }
  194. decision := classifyGrokUpstreamFailure(statusCode, responseBody, "")
  195. switch decision.Class {
  196. case GrokFailureFreeUsage, GrokFailureEmptyUpstream, GrokFailureBilling, GrokFailureModelCapacity, GrokFailureCompatibility:
  197. return decision.ShouldFailover
  198. }
  199. return s.shouldFailoverUpstreamError(statusCode)
  200. }
  201. func isGrokDecoderCompatibilityError(statusCode int, responseBody []byte) bool {
  202. if statusCode != http.StatusUnprocessableEntity || len(responseBody) == 0 {
  203. return false
  204. }
  205. for _, candidate := range grokStructuredErrorMessageCandidates(responseBody) {
  206. message := strings.ToLower(candidate)
  207. decoderSignal := strings.Contains(message, "untagged enum") ||
  208. strings.Contains(message, "decode") ||
  209. strings.Contains(message, "deserialize") ||
  210. strings.Contains(message, "deserializ") ||
  211. strings.Contains(message, "decoder")
  212. inputSignal := strings.Contains(message, "modelinput") ||
  213. strings.Contains(message, "model input") ||
  214. strings.Contains(message, "input[") ||
  215. strings.Contains(message, "input.")
  216. messageContentSignal := (strings.Contains(message, "messages[") ||
  217. strings.Contains(message, "messages.")) &&
  218. strings.Contains(message, "content") &&
  219. strings.Contains(message, "did not match any variant")
  220. if decoderSignal && (inputSignal || messageContentSignal) {
  221. return true
  222. }
  223. }
  224. return false
  225. }
  226. func grokStructuredErrorMessageCandidates(body []byte) []string {
  227. candidates := make([]string, 0, 6)
  228. appendCandidate := func(result gjson.Result) {
  229. if !result.Exists() {
  230. return
  231. }
  232. value := strings.TrimSpace(result.String())
  233. if value != "" {
  234. candidates = append(candidates, value)
  235. }
  236. }
  237. appendCandidate(gjson.GetBytes(body, "error.message"))
  238. appendCandidate(gjson.GetBytes(body, "error.error"))
  239. errorNode := gjson.GetBytes(body, "error")
  240. if errorNode.Type == gjson.String {
  241. appendCandidate(errorNode)
  242. }
  243. appendCandidate(gjson.GetBytes(body, "message"))
  244. appendCandidate(gjson.GetBytes(body, "detail"))
  245. if !json.Valid(body) {
  246. if plaintext := strings.TrimSpace(string(body)); plaintext != "" {
  247. candidates = append(candidates, plaintext)
  248. }
  249. }
  250. return candidates
  251. }
  252. // applyGrokForbiddenPolicy applies an administrator's existing temporary
  253. // unschedulable rules to a non-content 403. It reports true only when a rule
  254. // matched; unmatched responses retain the legacy entitlement cooldown.
  255. func (s *OpenAIGatewayService) applyGrokForbiddenPolicy(ctx context.Context, account *Account, responseBody []byte) bool {
  256. if account == nil || !account.IsTempUnschedulableEnabled() {
  257. return false
  258. }
  259. matches := matchTempUnschedulableRules(account, http.StatusForbidden, responseBody)
  260. if len(matches) == 0 {
  261. return false
  262. }
  263. match := matches[0]
  264. // Reuse the central policy implementation when it has a repository. This
  265. // preserves the existing reason/cache format and avoids duplicating writes.
  266. if s != nil && s.rateLimitService != nil && s.rateLimitService.accountRepo != nil {
  267. stateCtx, cancel := openAIAccountStateContext(ctx)
  268. handled := s.rateLimitService.tryTempUnschedulable(
  269. stateCtx,
  270. account,
  271. http.StatusForbidden,
  272. responseBody,
  273. )
  274. cancel()
  275. if handled {
  276. return true
  277. }
  278. }
  279. // A partially constructed service (for example a unit-test gateway) still
  280. // honors the configured duration instead of silently falling back to 30m.
  281. cooldown := time.Duration(match.rule.DurationMinutes) * time.Minute
  282. if cooldown > 0 {
  283. s.tempUnscheduleGrok(ctx, account, cooldown, "grok configured forbidden rule")
  284. }
  285. return true
  286. }