openai_responses_astra_pro_failover_test.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. //go:build unit
  2. package handler
  3. import (
  4. "bytes"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "sync"
  9. "testing"
  10. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. // astraProRequestBody is the inbound OpenAI Responses request that must survive
  17. // the account-switch loop unchanged: gpt-6-astra with the official top-level
  18. // reasoning.mode/effort preserved by the service guard
  19. // (normalizeOpenAIResponsesReasoningMode), never downgraded mode -> effort.
  20. const astraProRequestBody = `{"model":"gpt-6-astra","stream":false,"input":"hello","reasoning":{"mode":"pro","effort":"max"}}`
  21. // astraProCodexURL is the OAuth codex Responses exit every forwarded attempt must hit.
  22. const astraProCodexURL = "https://chatgpt.com/backend-api/codex/responses"
  23. // astraProSuccessResponse is the winning account's non-stream success; it echoes
  24. // the structured reasoning.mode=pro so the client response must keep mode=pro.
  25. const astraProSuccessResponse = `{"id":"resp_astra","model":"gpt-6-astra","status":"completed",` +
  26. `"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],` +
  27. `"usage":{"input_tokens":1,"output_tokens":2},` +
  28. `"reasoning":{"mode":"pro","effort":"max"}}`
  29. // astraProForbiddenResponse: a plain upstream 403 that stays failover-eligible but
  30. // is not reclassified as access-state/credential failure nor a silent refusal.
  31. const astraProForbiddenResponse = `{"error":{"type":"forbidden_error","code":"forbidden","message":"Forbidden"}}`
  32. // astraProCapturedUpstream records URL/accountID/body per forwarded attempt.
  33. type astraProCapturedUpstream struct {
  34. service.HTTPUpstream
  35. mu sync.Mutex
  36. urls []string
  37. accountIDs []int64
  38. bodies [][]byte
  39. answers []*http.Response
  40. }
  41. func newAstraProCapturedUpstream(answers ...*http.Response) *astraProCapturedUpstream {
  42. return &astraProCapturedUpstream{answers: answers}
  43. }
  44. func (u *astraProCapturedUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
  45. u.mu.Lock()
  46. defer u.mu.Unlock()
  47. body, _ := io.ReadAll(req.Body)
  48. u.urls = append(u.urls, req.URL.String())
  49. u.accountIDs = append(u.accountIDs, accountID)
  50. u.bodies = append(u.bodies, body)
  51. var resp *http.Response
  52. if len(u.answers) > 0 {
  53. resp = u.answers[0]
  54. u.answers = u.answers[1:]
  55. }
  56. if resp == nil {
  57. resp = &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil))}
  58. }
  59. return resp, nil
  60. }
  61. func (u *astraProCapturedUpstream) snapshot() ([]string, []int64, [][]byte) {
  62. u.mu.Lock()
  63. defer u.mu.Unlock()
  64. return append([]string(nil), u.urls...), append([]int64(nil), u.accountIDs...), append([][]byte(nil), u.bodies...)
  65. }
  66. // newAstraProFailoverContext builds the gin context that drives handler.Responses
  67. // through the real failover loop for the astra pro request.
  68. func newAstraProFailoverContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) {
  69. t.Helper()
  70. groupID := int64(3132)
  71. req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(body)))
  72. req.Header.Set("Content-Type", "application/json")
  73. rec := httptest.NewRecorder()
  74. c, _ := gin.CreateTestContext(rec)
  75. c.Request = req
  76. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  77. ID: 99,
  78. GroupID: &groupID,
  79. Group: &service.Group{
  80. ID: groupID,
  81. Platform: service.PlatformOpenAI,
  82. },
  83. User: &service.User{ID: 100},
  84. })
  85. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0})
  86. return c, rec
  87. }
  88. func astra403() *http.Response {
  89. return &http.Response{
  90. StatusCode: http.StatusForbidden,
  91. Header: http.Header{"Content-Type": []string{"application/json"}},
  92. Body: io.NopCloser(bytes.NewBufferString(astraProForbiddenResponse)),
  93. }
  94. }
  95. func astra200() *http.Response {
  96. return &http.Response{
  97. StatusCode: http.StatusOK,
  98. Header: http.Header{"Content-Type": []string{"application/json"}},
  99. Body: io.NopCloser(bytes.NewBufferString(astraProSuccessResponse)),
  100. }
  101. }
  102. // assertAstraProWire: every forwarded attempt must hit the codex responses URL and
  103. // keep model=gpt-6-astra + reasoning.mode=pro + effort=max (no silent downgrade).
  104. func assertAstraProWire(t *testing.T, urls []string, bodies [][]byte) {
  105. t.Helper()
  106. require.NotEmpty(t, urls, "failover loop must reach the upstream transport")
  107. for i, u := range urls {
  108. require.Equal(t, astraProCodexURL, u, "attempt %d must target the codex responses URL", i)
  109. }
  110. for i, body := range bodies {
  111. require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String(), "attempt %d must keep model", i)
  112. require.Equal(t, "pro", gjson.GetBytes(body, "reasoning.mode").String(), "attempt %d must keep mode=pro", i)
  113. require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String(), "attempt %d must keep effort=max", i)
  114. }
  115. }
  116. // assertAstraProAccountSwitch: the failover loop must have actually touched both
  117. // distinct OAuth fixture accounts (IDs 1 and 2), not replayed the same one.
  118. func assertAstraProAccountSwitch(t *testing.T, accountIDs []int64) {
  119. t.Helper()
  120. require.Len(t, accountIDs, 2, "failover must attempt two accounts")
  121. require.ElementsMatch(t, []int64{1, 2}, accountIDs, "both OAuth accounts must be reached")
  122. }
  123. // 403 then a second OAuth account succeeding: wire bodies keep pro+max/model and the
  124. // winning account's structured reasoning.mode=pro reaches the client unchanged.
  125. func TestOpenAIGatewayHandlerResponses_AstraProFirst403SecondSucceeds(t *testing.T) {
  126. gin.SetMode(gin.TestMode)
  127. upstream := newAstraProCapturedUpstream(astra403(), astra200())
  128. handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
  129. c, rec := newAstraProFailoverContext(t, astraProRequestBody)
  130. handler.Responses(c)
  131. urls, accountIDs, bodies := upstream.snapshot()
  132. assertAstraProWire(t, urls, bodies)
  133. assertAstraProAccountSwitch(t, accountIDs)
  134. require.Equal(t, http.StatusOK, rec.Code, "winning account must yield a 200 to the client")
  135. require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "status").String())
  136. require.Equal(t, "pro", gjson.GetBytes(rec.Body.Bytes(), "reasoning.mode").String(), "client must keep the winning response's mode=pro")
  137. require.Equal(t, "max", gjson.GetBytes(rec.Body.Bytes(), "reasoning.effort").String())
  138. }
  139. // both OAuth accounts 403: exhausted keeps every wire body pro+max/model, is not 200,
  140. // and surfaces the existing-policy 502 upstream_error (unchanged 403-masking policy).
  141. func TestOpenAIGatewayHandlerResponses_AstraProBoth403NoDowngrade(t *testing.T) {
  142. gin.SetMode(gin.TestMode)
  143. upstream := newAstraProCapturedUpstream(astra403(), astra403())
  144. handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
  145. c, rec := newAstraProFailoverContext(t, astraProRequestBody)
  146. handler.Responses(c)
  147. urls, accountIDs, bodies := upstream.snapshot()
  148. assertAstraProWire(t, urls, bodies)
  149. assertAstraProAccountSwitch(t, accountIDs)
  150. require.NotEqual(t, http.StatusOK, rec.Code, "all-403 must never surface a 200 to the client")
  151. require.Equal(t, http.StatusBadGateway, rec.Code)
  152. require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
  153. }
  154. // passthrough -> non-passthrough drops only the encrypted *input* reasoning item;
  155. // the official top-level reasoning.mode/effort survive verbatim.
  156. func TestDeriveOpenAIForwardAttemptBody_AstraCrossModeKeepsTopLevelReasoningMode(t *testing.T) {
  157. h := &OpenAIGatewayHandler{}
  158. canonical := []byte(`{"model":"gpt-6-astra","stream":false,"reasoning":{"mode":"pro","effort":"max"},"input":[` +
  159. `{"type":"message","role":"user","content":"hello"},` +
  160. `{"type":"reasoning","id":"rs_kiro_abc","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]}` +
  161. `]}`)
  162. canonicalSnapshot := append([]byte(nil), canonical...)
  163. state := &openAIPassthroughFailoverState{}
  164. kiro := newOpenAIPassthroughAccount(1, true) // passthrough first
  165. bedrock := newOpenAIPassthroughAccount(2, false) // non-passthrough after
  166. firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
  167. require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt keeps the encrypted input reasoning item")
  168. require.Equal(t, "pro", gjson.GetBytes(firstBody, "reasoning.mode").String())
  169. require.Equal(t, "max", gjson.GetBytes(firstBody, "reasoning.effort").String())
  170. secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state)
  171. require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt drops the encrypted input reasoning item")
  172. require.Equal(t, "pro", gjson.GetBytes(secondBody, "reasoning.mode").String(), "mode=pro must survive cross-mode sanitization")
  173. require.Equal(t, "max", gjson.GetBytes(secondBody, "reasoning.effort").String(), "effort=max must survive cross-mode sanitization")
  174. require.Equal(t, "gpt-6-astra", gjson.GetBytes(secondBody, "model").String())
  175. require.JSONEq(t, string(canonicalSnapshot), string(canonical), "canonical forwardBody must never be mutated")
  176. require.True(t, gjson.GetBytes(canonical, "input.1.encrypted_content").Exists())
  177. }