openai_astra_pro_retry_test.go 5.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/require"
  10. "github.com/tidwall/gjson"
  11. )
  12. const openAICodexResponsesTestURL = "https://chatgpt.com/backend-api/codex/responses"
  13. // newOpenAIAstraProRetryContext returns a gin context plus its recorder so the
  14. // caller can assert the service-layer client error shape (type/code/param/message).
  15. func newOpenAIAstraProRetryContext(body []byte) (*gin.Context, *httptest.ResponseRecorder) {
  16. gin.SetMode(gin.TestMode)
  17. recorder := httptest.NewRecorder()
  18. c, _ := gin.CreateTestContext(recorder)
  19. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  20. c.Request.Header.Set("Content-Type", "application/json")
  21. return c, recorder
  22. }
  23. // TestOpenAIGatewayService_OAuthAstraProModeRejectionPassesThrough verifies that
  24. // when the Codex OAuth upstream rejects an Astra pro+max request with a
  25. // deterministic HTTP 400 on error.param=reasoning.mode, the gateway passes the
  26. // client error through unchanged (upstream type/code/param/message, code not
  27. // treated as official), makes exactly one upstream call, keeps reasoning.mode=pro
  28. // and reasoning.effort=max in the sent body, and does not replay without mode.
  29. func TestOpenAIGatewayService_OAuthAstraProModeRejectionPassesThrough(t *testing.T) {
  30. body := []byte(`{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"input":"hello"}`)
  31. upstream := &httpUpstreamRecorder{resp: newOpenAIRejectedFieldTestResponse(
  32. http.StatusBadRequest,
  33. `{"error":{"type":"invalid_request_error","code":"model_specific_rejection","message":"reasoning.mode is not supported for this model","param":"reasoning.mode"}}`,
  34. )}
  35. svc := newOpenAIRejectedFieldTestService(upstream)
  36. c, recorder := newOpenAIAstraProRetryContext(body)
  37. _, err := svc.Forward(context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body)
  38. require.Error(t, err)
  39. require.Equal(t, http.StatusBadRequest, recorder.Code)
  40. require.Len(t, upstream.bodies, 1, "deterministic 400 must not trigger a retry")
  41. require.Len(t, upstream.requests, 1)
  42. require.Equal(t, openAICodexResponsesTestURL, upstream.requests[0].URL.String())
  43. sent := upstream.bodies[0]
  44. require.Equal(t, "gpt-6-astra", gjson.GetBytes(sent, "model").String())
  45. require.Equal(t, "pro", gjson.GetBytes(sent, "reasoning.mode").String())
  46. require.Equal(t, "max", gjson.GetBytes(sent, "reasoning.effort").String())
  47. respJSON := recorder.Body.String()
  48. require.Equal(t, "invalid_request_error", gjson.Get(respJSON, "error.type").String())
  49. require.Equal(t, "model_specific_rejection", gjson.Get(respJSON, "error.code").String())
  50. require.Equal(t, "reasoning.mode", gjson.Get(respJSON, "error.param").String())
  51. require.Equal(t, "reasoning.mode is not supported for this model", gjson.Get(respJSON, "error.message").String())
  52. }
  53. // TestOpenAIGatewayService_OAuthAstraProModeKeptAcrossRejectedFieldRetry reuses
  54. // the existing OAuth rejected-field retry fixture (input[0].status is rejected,
  55. // stripped, then replayed to success) to confirm Astra pro+max reasoning is kept
  56. // across every in-service retry: each captured Codex URL request body still
  57. // carries reasoning.mode=pro and reasoning.effort=max with model=gpt-6-astra,
  58. // and only the second attempt drops the rejected status field.
  59. func TestOpenAIGatewayService_OAuthAstraProModeKeptAcrossRejectedFieldRetry(t *testing.T) {
  60. body := []byte(`{"model":"gpt-6-astra","stream":true,"instructions":"test","reasoning":{"mode":"pro","effort":"max"},"input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`)
  61. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  62. newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`),
  63. newOpenAIRejectedFieldTestResponse(http.StatusOK, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"),
  64. }}
  65. upstream.responses[1].Header.Set("Content-Type", "text/event-stream")
  66. svc := newOpenAIRejectedFieldTestService(upstream)
  67. c, _ := newOpenAIAstraProRetryContext(body)
  68. result, err := svc.Forward(context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body)
  69. require.NoError(t, err)
  70. require.NotNil(t, result)
  71. require.Len(t, upstream.bodies, 2)
  72. require.Len(t, upstream.requests, 2)
  73. for i := range upstream.bodies {
  74. require.Equal(t, openAICodexResponsesTestURL, upstream.requests[i].URL.String(), "attempt %d URL", i)
  75. sent := upstream.bodies[i]
  76. require.Equal(t, "gpt-6-astra", gjson.GetBytes(sent, "model").String(), "attempt %d model", i)
  77. require.Equal(t, "pro", gjson.GetBytes(sent, "reasoning.mode").String(), "attempt %d reasoning.mode", i)
  78. require.Equal(t, "max", gjson.GetBytes(sent, "reasoning.effort").String(), "attempt %d reasoning.effort", i)
  79. }
  80. require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String())
  81. require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists())
  82. }