grok_upstream_errors_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "time"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. func TestIsGrokContentPolicyRejection(t *testing.T) {
  16. tests := []struct {
  17. name string
  18. status int
  19. body string
  20. want bool
  21. }{
  22. {
  23. name: "new sensitive code",
  24. status: http.StatusForbidden,
  25. body: `{"error":{"code":"new_sensitive","message":"image is sensitive"}}`,
  26. want: true,
  27. },
  28. {
  29. name: "content policy violation code",
  30. status: http.StatusForbidden,
  31. body: `{"response":{"error":{"code":"content_policy_violation"}}}`,
  32. want: true,
  33. },
  34. {
  35. name: "cyber policy code",
  36. status: http.StatusForbidden,
  37. body: `{"error":{"code":"cyber_policy","message":"request rejected"}}`,
  38. want: true,
  39. },
  40. {
  41. name: "moderation feature unavailable",
  42. status: http.StatusForbidden,
  43. body: `{"error":{"message":"The moderation feature is not available for this request"}}`,
  44. want: true,
  45. },
  46. {
  47. name: "explicit prompt moderation rejection",
  48. status: http.StatusForbidden,
  49. body: `{"error":{"message":"request rejected by content moderation"}}`,
  50. want: true,
  51. },
  52. {
  53. name: "entitlement forbidden",
  54. status: http.StatusForbidden,
  55. body: `{"error":{"message":"subscription required"}}`,
  56. want: false,
  57. },
  58. {
  59. name: "account policy suspension is not request policy",
  60. status: http.StatusForbidden,
  61. body: `{"error":{"message":"account suspended due to policy violation"}}`,
  62. want: false,
  63. },
  64. {
  65. name: "structured account suspension overrides policy reason",
  66. status: http.StatusForbidden,
  67. body: `{"error":{"code":"account_suspended","reason":"policy_violation","message":"account suspended due to policy violation"}}`,
  68. want: false,
  69. },
  70. {
  71. name: "ambiguous policy violation code is not enough",
  72. status: http.StatusForbidden,
  73. body: `{"error":{"code":"policy_violation","message":"policy violation"}}`,
  74. want: false,
  75. },
  76. {
  77. name: "policy violation with request scoped message",
  78. status: http.StatusForbidden,
  79. body: `{"error":{"code":"policy_violation","message":"request blocked by policy"}}`,
  80. want: true,
  81. },
  82. {
  83. name: "permission-denied usage guidelines is request scoped",
  84. status: http.StatusForbidden,
  85. body: `{"code":"permission-denied","error":"Content violates usage guidelines. "}`,
  86. want: true,
  87. },
  88. {
  89. name: "permission-denied entitlement stays on the account path",
  90. status: http.StatusForbidden,
  91. body: `{"code":"permission-denied","error":"Access to the chat endpoint is denied"}`,
  92. want: false,
  93. },
  94. {
  95. name: "structured account code overrides usage guidelines phrase",
  96. status: http.StatusForbidden,
  97. body: `{"error":{"code":"account_suspended","message":"Content violates usage guidelines."}}`,
  98. want: false,
  99. },
  100. {
  101. name: "wrong status",
  102. status: http.StatusBadRequest,
  103. body: `{"error":{"code":"new_sensitive"}}`,
  104. want: false,
  105. },
  106. }
  107. for _, tt := range tests {
  108. t.Run(tt.name, func(t *testing.T) {
  109. require.Equal(t, tt.want, isGrokContentPolicyRejection(tt.status, []byte(tt.body)))
  110. })
  111. }
  112. }
  113. func TestGrokContentPolicy403DoesNotMutateOrFailover(t *testing.T) {
  114. repo := &grokQuotaAccountRepo{}
  115. svc := &OpenAIGatewayService{accountRepo: repo}
  116. account := &Account{ID: 4715, Platform: PlatformGrok, Type: AccountTypeOAuth}
  117. body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`)
  118. svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body)
  119. require.Zero(t, repo.tempUnschedCalls)
  120. require.Zero(t, repo.rateLimitedCalls)
  121. require.Zero(t, repo.updateCalls)
  122. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  123. require.False(t, svc.shouldFailoverGrokUpstreamError(http.StatusForbidden, body))
  124. gin.SetMode(gin.TestMode)
  125. recorder := httptest.NewRecorder()
  126. c, _ := gin.CreateTestContext(recorder)
  127. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  128. resp := &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}}
  129. got := svc.failoverOpenAIUpstreamHTTPError(context.Background(), c, account, resp, body, "text is sensitive", "grok-4.5")
  130. require.Nil(t, got)
  131. require.Zero(t, repo.tempUnschedCalls)
  132. }
  133. func TestGrokNonFailoverDoesNotApplyGenericTempUnschedulablePolicy(t *testing.T) {
  134. gin.SetMode(gin.TestMode)
  135. repo := &grokQuotaAccountRepo{}
  136. svc := &OpenAIGatewayService{
  137. accountRepo: repo,
  138. rateLimitService: NewRateLimitService(repo, nil, nil, nil, nil),
  139. }
  140. account := &Account{
  141. ID: 5099,
  142. Platform: PlatformGrok,
  143. Type: AccountTypeOAuth,
  144. Credentials: map[string]any{
  145. "temp_unschedulable_enabled": true,
  146. "temp_unschedulable_rules": []any{map[string]any{
  147. "error_code": float64(http.StatusForbidden),
  148. "keywords": []any{"text is sensitive"},
  149. "duration_minutes": float64(1),
  150. }},
  151. },
  152. }
  153. body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`)
  154. recorder := httptest.NewRecorder()
  155. c, _ := gin.CreateTestContext(recorder)
  156. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  157. resp := &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}}
  158. got := svc.failoverOpenAIUpstreamHTTPError(
  159. context.Background(), c, account, resp, body, "text is sensitive", "",
  160. )
  161. require.Nil(t, got)
  162. require.Zero(t, repo.tempUnschedCalls)
  163. require.Zero(t, repo.rateLimitedCalls)
  164. require.Zero(t, repo.updateCalls)
  165. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  166. }
  167. func TestGrokContentPolicy403SharedErrorFallbackDoesNotMutate(t *testing.T) {
  168. gin.SetMode(gin.TestMode)
  169. body := []byte(`{"error":{"code":"content_filter","message":"prohibited content"}}`)
  170. repo := &grokQuotaAccountRepo{}
  171. svc := &OpenAIGatewayService{accountRepo: repo}
  172. account := &Account{
  173. ID: 4719,
  174. Platform: PlatformGrok,
  175. Type: AccountTypeOAuth,
  176. Credentials: map[string]any{
  177. "custom_error_codes_enabled": true,
  178. "custom_error_codes": []any{float64(http.StatusTooManyRequests)},
  179. },
  180. }
  181. newContext := func() (*gin.Context, *httptest.ResponseRecorder) {
  182. recorder := httptest.NewRecorder()
  183. c, _ := gin.CreateTestContext(recorder)
  184. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  185. return c, recorder
  186. }
  187. c, recorder := newContext()
  188. resp := &http.Response{
  189. StatusCode: http.StatusForbidden,
  190. Header: http.Header{"Content-Type": []string{"application/json"}},
  191. Body: io.NopCloser(strings.NewReader(string(body))),
  192. }
  193. _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil, "grok-4.5")
  194. require.Error(t, err)
  195. require.Equal(t, http.StatusForbidden, recorder.Code)
  196. require.Contains(t, recorder.Body.String(), "invalid_request_error")
  197. c, recorder = newContext()
  198. resp = &http.Response{
  199. StatusCode: http.StatusForbidden,
  200. Header: http.Header{"Content-Type": []string{"application/json"}},
  201. Body: io.NopCloser(strings.NewReader(string(body))),
  202. }
  203. _, err = svc.handleCompatErrorResponse(resp, c, account, writeChatCompletionsError, "grok-4.5")
  204. require.Error(t, err)
  205. require.Equal(t, http.StatusForbidden, recorder.Code)
  206. require.Contains(t, recorder.Body.String(), "invalid_request_error")
  207. require.Zero(t, repo.tempUnschedCalls)
  208. require.Zero(t, repo.rateLimitedCalls)
  209. require.Zero(t, repo.updateCalls)
  210. }
  211. func TestGrokContentPolicy403MediaResponseBypassesCustomErrorCodes(t *testing.T) {
  212. gin.SetMode(gin.TestMode)
  213. body := `{"error":{"code":"new_sensitive","message":"image is sensitive"}}`
  214. repo := &grokQuotaAccountRepo{}
  215. svc := &OpenAIGatewayService{accountRepo: repo}
  216. account := &Account{
  217. ID: 4720,
  218. Platform: PlatformGrok,
  219. Type: AccountTypeOAuth,
  220. Credentials: map[string]any{
  221. "custom_error_codes_enabled": true,
  222. "custom_error_codes": []any{float64(http.StatusTooManyRequests)},
  223. },
  224. }
  225. recorder := httptest.NewRecorder()
  226. c, _ := gin.CreateTestContext(recorder)
  227. c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
  228. resp := &http.Response{
  229. StatusCode: http.StatusForbidden,
  230. Header: http.Header{"Content-Type": []string{"application/json"}},
  231. Body: io.NopCloser(strings.NewReader(body)),
  232. }
  233. _, err := svc.handleGrokMediaErrorResponse(context.Background(), resp, c, account, "request-id", "grok-imagine")
  234. require.Error(t, err)
  235. require.Equal(t, http.StatusForbidden, recorder.Code)
  236. require.Contains(t, recorder.Body.String(), "invalid_request_error")
  237. require.Zero(t, repo.tempUnschedCalls)
  238. require.Zero(t, repo.rateLimitedCalls)
  239. require.Zero(t, repo.updateCalls)
  240. }
  241. func TestGrokContentPolicySSEErrorDoesNotMutateOrFailover(t *testing.T) {
  242. gin.SetMode(gin.TestMode)
  243. repo := &grokQuotaAccountRepo{}
  244. upstream := &httpUpstreamRecorder{resp: &http.Response{
  245. StatusCode: http.StatusOK,
  246. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  247. Body: io.NopCloser(strings.NewReader(
  248. "data: {\"type\":\"error\",\"error\":{\"code\":\"new_sensitive\",\"message\":\"text is sensitive\"}}\n\n",
  249. )),
  250. }}
  251. svc := &OpenAIGatewayService{accountRepo: repo, httpUpstream: upstream}
  252. account := &Account{ID: 4721, Platform: PlatformGrok, Type: AccountTypeOAuth, Concurrency: 1}
  253. recorder := httptest.NewRecorder()
  254. c, _ := gin.CreateTestContext(recorder)
  255. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  256. payload := []byte(`{"type":"response.create","model":"grok-4.5","input":"hi"}`)
  257. var writes [][]byte
  258. result, err := svc.proxyOpenAIWSHTTPBridgeTurn(
  259. context.Background(), c, account, "access-token", payload, len(payload),
  260. "grok-4.5", "", "", "", "cache-id", 1,
  261. func(message []byte) error {
  262. writes = append(writes, append([]byte(nil), message...))
  263. return nil
  264. },
  265. )
  266. require.Error(t, err)
  267. require.NotNil(t, result)
  268. var failoverErr *UpstreamFailoverError
  269. require.False(t, errors.As(err, &failoverErr))
  270. require.Len(t, writes, 1)
  271. require.Contains(t, string(writes[0]), "new_sensitive")
  272. require.Zero(t, repo.tempUnschedCalls)
  273. require.Zero(t, repo.rateLimitedCalls)
  274. require.Zero(t, repo.updateCalls)
  275. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  276. }
  277. func TestGrokPermissionDeniedContentRefusalDoesNotMutateOrFailover(t *testing.T) {
  278. repo := &grokQuotaAccountRepo{}
  279. svc := &OpenAIGatewayService{accountRepo: repo}
  280. account := &Account{ID: 4785, Platform: PlatformGrok, Type: AccountTypeOAuth}
  281. body := []byte(`{"code":"permission-denied","error":"Content violates usage guidelines. "}`)
  282. svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusForbidden, nil, body)
  283. require.Zero(t, repo.tempUnschedCalls)
  284. require.Zero(t, repo.rateLimitedCalls)
  285. require.Zero(t, repo.updateCalls)
  286. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  287. require.False(t, svc.shouldFailoverGrokUpstreamError(http.StatusForbidden, body))
  288. }
  289. func TestHandleGrokAccountUpstreamErrorEntitlement403KeepsDefaultCooldown(t *testing.T) {
  290. repo := &grokQuotaAccountRepo{}
  291. svc := &OpenAIGatewayService{accountRepo: repo}
  292. account := &Account{ID: 4716, Platform: PlatformGrok, Type: AccountTypeOAuth}
  293. before := time.Now()
  294. svc.handleGrokAccountUpstreamError(
  295. context.Background(), account, http.StatusForbidden, nil,
  296. []byte(`{"error":{"message":"subscription required"}}`),
  297. )
  298. require.Equal(t, 1, repo.tempUnschedCalls)
  299. require.Equal(t, "grok access or entitlement denied", repo.lastTempUnschedReason)
  300. require.Greater(t, repo.lastTempUnschedUntil, before.Add(29*time.Minute))
  301. require.Less(t, repo.lastTempUnschedUntil, before.Add(31*time.Minute))
  302. }
  303. func TestHandleGrokAccountUpstreamErrorDefaultCooldownsRespectPoolMode(t *testing.T) {
  304. for _, statusCode := range []int{
  305. http.StatusUnauthorized,
  306. http.StatusPaymentRequired,
  307. http.StatusForbidden,
  308. http.StatusInternalServerError,
  309. } {
  310. t.Run(http.StatusText(statusCode), func(t *testing.T) {
  311. repo := &grokQuotaAccountRepo{}
  312. svc := &OpenAIGatewayService{accountRepo: repo}
  313. account := &Account{
  314. ID: int64(4800 + statusCode),
  315. Platform: PlatformGrok,
  316. Type: AccountTypeAPIKey,
  317. Credentials: map[string]any{
  318. "pool_mode": true,
  319. },
  320. }
  321. body := []byte(`{"error":{"message":"grok access or entitlement denied"}}`)
  322. svc.handleGrokAccountUpstreamError(
  323. context.Background(), account, statusCode, nil, body,
  324. )
  325. require.Zero(t, repo.tempUnschedCalls)
  326. require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
  327. require.Nil(t, account.TempUnschedulableUntil)
  328. require.Empty(t, account.TempUnschedulableReason)
  329. require.True(t, svc.shouldFailoverGrokUpstreamError(statusCode, body))
  330. })
  331. }
  332. account := &Account{Type: AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true}}
  333. require.True(t, account.IsPoolModeRetryableStatus(http.StatusForbidden))
  334. t.Run("explicit temporary rule still applies", func(t *testing.T) {
  335. repo := &grokQuotaAccountRepo{}
  336. svc := &OpenAIGatewayService{accountRepo: repo}
  337. account := &Account{
  338. ID: 4723,
  339. Platform: PlatformGrok,
  340. Type: AccountTypeAPIKey,
  341. Credentials: map[string]any{
  342. "pool_mode": true,
  343. "temp_unschedulable_enabled": true,
  344. "temp_unschedulable_rules": []any{
  345. map[string]any{
  346. "error_code": float64(http.StatusForbidden),
  347. "keywords": []any{"entitlement denied"},
  348. "duration_minutes": float64(7),
  349. },
  350. },
  351. },
  352. }
  353. before := time.Now()
  354. svc.handleGrokAccountUpstreamError(
  355. context.Background(), account, http.StatusForbidden, nil,
  356. []byte(`{"error":{"message":"grok access or entitlement denied"}}`),
  357. )
  358. require.Equal(t, 1, repo.tempUnschedCalls)
  359. require.Equal(t, "grok configured forbidden rule", repo.lastTempUnschedReason)
  360. require.WithinDuration(t, before.Add(7*time.Minute), repo.lastTempUnschedUntil, time.Second)
  361. require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
  362. })
  363. }
  364. func TestHandleGrokAccountUpstreamError403UsesConfiguredRule(t *testing.T) {
  365. repo := &grokQuotaAccountRepo{}
  366. svc := &OpenAIGatewayService{accountRepo: repo}
  367. account := &Account{
  368. ID: 4717,
  369. Platform: PlatformGrok,
  370. Type: AccountTypeOAuth,
  371. Credentials: map[string]any{
  372. "temp_unschedulable_enabled": true,
  373. "temp_unschedulable_rules": []any{
  374. map[string]any{
  375. "error_code": float64(http.StatusForbidden),
  376. "keywords": []any{"subscription"},
  377. "duration_minutes": float64(7),
  378. },
  379. },
  380. },
  381. }
  382. before := time.Now()
  383. svc.handleGrokAccountUpstreamError(
  384. context.Background(), account, http.StatusForbidden, nil,
  385. []byte(`{"error":{"message":"subscription required"}}`),
  386. )
  387. require.Equal(t, 1, repo.tempUnschedCalls)
  388. require.Greater(t, repo.lastTempUnschedUntil, before.Add(6*time.Minute))
  389. require.Less(t, repo.lastTempUnschedUntil, before.Add(8*time.Minute))
  390. }
  391. func TestHandleGrokAccountUpstreamError403ConfiguredUnmatchedKeepsDefaultCooldown(t *testing.T) {
  392. repo := &grokQuotaAccountRepo{}
  393. svc := &OpenAIGatewayService{accountRepo: repo}
  394. account := &Account{
  395. ID: 4718,
  396. Platform: PlatformGrok,
  397. Type: AccountTypeOAuth,
  398. Credentials: map[string]any{
  399. "temp_unschedulable_enabled": true,
  400. "temp_unschedulable_rules": []any{
  401. map[string]any{
  402. "error_code": float64(http.StatusForbidden),
  403. "keywords": []any{"different failure"},
  404. "duration_minutes": float64(7),
  405. },
  406. },
  407. },
  408. }
  409. svc.handleGrokAccountUpstreamError(
  410. context.Background(), account, http.StatusForbidden, nil,
  411. []byte(`{"error":{"message":"subscription required"}}`),
  412. )
  413. require.Equal(t, 1, repo.tempUnschedCalls)
  414. require.Equal(t, "grok access or entitlement denied", repo.lastTempUnschedReason)
  415. require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
  416. }