openai_astra_pro_mode_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. //go:build unit
  2. package service
  3. // Integration-level tests for GPT-6 Astra reasoning.mode preservation through
  4. // OpenAIGatewayService.Forward.
  5. //
  6. // These exercise the real forward pipeline through the httpUpstreamRecorder mock
  7. // (no real network / credentials / config), covering the OpenAI OAuth account
  8. // (native Codex transform when passthrough is disabled, and the passthrough
  9. // branch when enabled). Client stream=true and stream=false both receive an
  10. // upstream SSE fixture, because Codex upstreams stream: the stream=false client
  11. // path exercises the real SSE->JSON aggregation instead of a synthetic JSON body.
  12. import (
  13. "context"
  14. "io"
  15. "net/http"
  16. "net/http/httptest"
  17. "strings"
  18. "testing"
  19. "github.com/Wei-Shaw/sub2api/internal/config"
  20. "github.com/gin-gonic/gin"
  21. "github.com/stretchr/testify/require"
  22. "github.com/tidwall/gjson"
  23. )
  24. const astraProCodexResponsesURL = "https://chatgpt.com/backend-api/codex/responses"
  25. type astraForwardSetup struct {
  26. upstream *httpUpstreamRecorder
  27. svc *OpenAIGatewayService
  28. c *gin.Context
  29. rec *httptest.ResponseRecorder
  30. account *Account
  31. }
  32. // newAstraOAuthSetup builds an OpenAI OAuth account using the minimal
  33. // svc+account harness pattern (mirrors openai_oauth_passthrough_test.go). When
  34. // passthrough is true the account routes through forwardOpenAIPassthrough.
  35. func newAstraOAuthSetup(t *testing.T, passthrough bool) *astraForwardSetup {
  36. t.Helper()
  37. gin.SetMode(gin.TestMode)
  38. rec := httptest.NewRecorder()
  39. c, _ := gin.CreateTestContext(rec)
  40. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  41. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
  42. upstream := &httpUpstreamRecorder{}
  43. svc := &OpenAIGatewayService{
  44. cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
  45. httpUpstream: upstream,
  46. }
  47. account := &Account{
  48. ID: 888,
  49. Name: "oauth-astra",
  50. Platform: PlatformOpenAI,
  51. Type: AccountTypeOAuth,
  52. Concurrency: 1,
  53. Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
  54. Extra: map[string]any{"openai_passthrough": passthrough},
  55. Status: StatusActive,
  56. Schedulable: true,
  57. RateMultiplier: f64p(1),
  58. }
  59. return &astraForwardSetup{upstream: upstream, svc: svc, c: c, rec: rec, account: account}
  60. }
  61. func codexCompletedSSE(inner string) string {
  62. return "data: {\"type\":\"response.completed\",\"response\":" + inner + "}\n\ndata: [DONE]\n\n"
  63. }
  64. func astraRequestBody(model string, stream bool, mode, effort string) []byte {
  65. var reasoningParts []string
  66. if mode != "" {
  67. reasoningParts = append(reasoningParts, `"mode":"`+mode+`"`)
  68. }
  69. if effort != "" {
  70. reasoningParts = append(reasoningParts, `"effort":"`+effort+`"`)
  71. }
  72. reasoning := ""
  73. if len(reasoningParts) > 0 {
  74. reasoning = `,"reasoning":{` + strings.Join(reasoningParts, ",") + `}`
  75. }
  76. streamJSON := "false"
  77. if stream {
  78. streamJSON = "true"
  79. }
  80. return []byte(`{"model":"` + model + `","stream":` + streamJSON + `,` +
  81. `"instructions":"test","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]` +
  82. reasoning + `}`)
  83. }
  84. // TestForward_AstraOAuth_NonAstraLegacyStillStrips asserts the historical strip
  85. // behavior is preserved for a non-Astra model on an OAuth account.
  86. func TestForward_AstraOAuth_NonAstraLegacyStillStrips(t *testing.T) {
  87. gin.SetMode(gin.TestMode)
  88. s := newAstraOAuthSetup(t, false)
  89. inner := `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
  90. s.upstream.resp = &http.Response{
  91. StatusCode: http.StatusOK,
  92. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  93. Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
  94. }
  95. body := astraRequestBody("gpt-5.6-sol", true, "pro", "")
  96. result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
  97. require.NoError(t, err)
  98. require.NotNil(t, result)
  99. require.NotNil(t, s.upstream.lastReq)
  100. require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String())
  101. forwarded := s.upstream.lastBody
  102. require.False(t, gjson.GetBytes(forwarded, "reasoning.mode").Exists(),
  103. "non-Astra legacy must strip reasoning.mode")
  104. require.Equal(t, "max", gjson.GetBytes(forwarded, "reasoning.effort").String(),
  105. "non-Astra legacy mode=pro without effort injects max")
  106. }
  107. // TestForward_AstraOAuth_ModeMatrix_Preserved runs the pro/standard/missing-mode
  108. // x effort=max matrix across both forward branches and both client stream modes.
  109. // Every attempt receives an upstream SSE fixture; assertions check the final
  110. // upstream request keeps mode/effort and the URL is the Codex responses endpoint.
  111. func TestForward_AstraOAuth_ModeMatrix_Preserved(t *testing.T) {
  112. cases := []struct {
  113. name string
  114. mode string
  115. effort string
  116. wantMod string // gjson string value; "" == absent
  117. wantEff string
  118. }{
  119. {name: "pro+max", mode: "pro", effort: "max", wantMod: "pro", wantEff: "max"},
  120. {name: "standard+max", mode: "standard", effort: "max", wantMod: "standard", wantEff: "max"},
  121. {name: "missing mode + max", mode: "", effort: "max", wantMod: "", wantEff: "max"},
  122. {name: "pro+high", mode: "pro", effort: "high", wantMod: "pro", wantEff: "high"},
  123. {name: "pro no effort", mode: "pro", effort: "", wantMod: "pro", wantEff: ""},
  124. }
  125. for _, passthrough := range []bool{false, true} {
  126. branch := "native-codex"
  127. if passthrough {
  128. branch = "passthrough"
  129. }
  130. for _, stream := range []bool{true, false} {
  131. for _, tt := range cases {
  132. t.Run(tt.name+"/"+branch+"/stream="+map[bool]string{true: "true", false: "false"}[stream], func(t *testing.T) {
  133. s := newAstraOAuthSetup(t, passthrough)
  134. inner := `{"id":"resp_test","model":"gpt-6-astra","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
  135. s.upstream.resp = &http.Response{
  136. StatusCode: http.StatusOK,
  137. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  138. Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
  139. }
  140. body := astraRequestBody("gpt-6-astra", stream, tt.mode, tt.effort)
  141. result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
  142. require.NoError(t, err)
  143. require.NotNil(t, result)
  144. require.NotNil(t, s.upstream.lastReq)
  145. require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String(),
  146. "OAuth account must hit the Codex responses endpoint")
  147. forwarded := s.upstream.lastBody
  148. require.Equal(t, "gpt-6-astra", gjson.GetBytes(forwarded, "model").String())
  149. mode := gjson.GetBytes(forwarded, "reasoning.mode")
  150. if tt.wantMod == "" {
  151. require.False(t, mode.Exists(), "mode should be absent for %q", tt.name)
  152. } else {
  153. require.True(t, mode.Exists(), "Astra mode must be preserved for %q", tt.name)
  154. require.Equal(t, tt.wantMod, mode.String())
  155. }
  156. eff := gjson.GetBytes(forwarded, "reasoning.effort")
  157. if tt.wantEff == "" {
  158. require.False(t, eff.Exists(), "no effort injected when omitted for %q", tt.name)
  159. } else {
  160. require.Equal(t, tt.wantEff, eff.String(), "Astra effort preserved for %q", tt.name)
  161. }
  162. })
  163. }
  164. }
  165. }
  166. }
  167. // TestForward_AstraOAuth_NonStreamAggregation_PreservesResponseReasoningMode
  168. // drives client stream=false (which is forced to an SSE upstream) and asserts the
  169. // aggregated JSON response object still carries response.reasoning.mode after
  170. // SSE->JSON aggregation.
  171. func TestForward_AstraOAuth_NonStreamAggregation_PreservesResponseReasoningMode(t *testing.T) {
  172. gin.SetMode(gin.TestMode)
  173. s := newAstraOAuthSetup(t, false)
  174. // response.completed.response carries a top-level reasoning:{mode:pro,effort:max}.
  175. inner := `{"id":"resp_astra","object":"response","created_at":0,"status":"completed","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"output":[{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"think"}]},{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
  176. s.upstream.resp = &http.Response{
  177. StatusCode: http.StatusOK,
  178. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  179. Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
  180. }
  181. body := astraRequestBody("gpt-6-astra", false, "pro", "max")
  182. result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
  183. require.NoError(t, err)
  184. require.NotNil(t, result)
  185. downstream := s.rec.Body.String()
  186. // Downstream is JSON (SSE->JSON aggregation for the non-streaming client).
  187. require.True(t, gjson.Valid(downstream), "downstream must be aggregated JSON, got: %s", downstream)
  188. require.Equal(t, "pro", gjson.Get(downstream, "reasoning.mode").String(),
  189. "aggregated response object must keep reasoning.mode")
  190. require.Equal(t, "max", gjson.Get(downstream, "reasoning.effort").String())
  191. }
  192. // TestForward_AstraOAuth_Stream_PreservesResponseReasoningMode drives client
  193. // stream=true and asserts the streamed response.completed event's response object
  194. // still carries response.reasoning.mode.
  195. func TestForward_AstraOAuth_Stream_PreservesResponseReasoningMode(t *testing.T) {
  196. gin.SetMode(gin.TestMode)
  197. s := newAstraOAuthSetup(t, false)
  198. inner := `{"id":"resp_astra","object":"response","created_at":0,"status":"completed","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
  199. s.upstream.resp = &http.Response{
  200. StatusCode: http.StatusOK,
  201. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  202. Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
  203. }
  204. body := astraRequestBody("gpt-6-astra", true, "pro", "max")
  205. result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
  206. require.NoError(t, err)
  207. require.NotNil(t, result)
  208. downstream := s.rec.Body.String()
  209. require.Contains(t, downstream, "response.completed", "streaming downstream must include completed event")
  210. // Extract response.completed line and assert response.reasoning.mode inside it.
  211. var completedJSON string
  212. for _, line := range strings.Split(downstream, "\n") {
  213. if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"response.completed"`) {
  214. completedJSON = strings.TrimPrefix(line, "data: ")
  215. break
  216. }
  217. }
  218. require.NotEmpty(t, completedJSON, "no response.completed data line found in: %s", downstream)
  219. require.Equal(t, "pro", gjson.Get(completedJSON, "response.reasoning.mode").String(),
  220. "streamed completed response object must keep reasoning.mode")
  221. require.Equal(t, "max", gjson.Get(completedJSON, "response.reasoning.effort").String())
  222. }