| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- //go:build unit
- package service
- // Integration-level tests for GPT-6 Astra reasoning.mode preservation through
- // OpenAIGatewayService.Forward.
- //
- // These exercise the real forward pipeline through the httpUpstreamRecorder mock
- // (no real network / credentials / config), covering the OpenAI OAuth account
- // (native Codex transform when passthrough is disabled, and the passthrough
- // branch when enabled). Client stream=true and stream=false both receive an
- // upstream SSE fixture, because Codex upstreams stream: the stream=false client
- // path exercises the real SSE->JSON aggregation instead of a synthetic JSON body.
- import (
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- const astraProCodexResponsesURL = "https://chatgpt.com/backend-api/codex/responses"
- type astraForwardSetup struct {
- upstream *httpUpstreamRecorder
- svc *OpenAIGatewayService
- c *gin.Context
- rec *httptest.ResponseRecorder
- account *Account
- }
- // newAstraOAuthSetup builds an OpenAI OAuth account using the minimal
- // svc+account harness pattern (mirrors openai_oauth_passthrough_test.go). When
- // passthrough is true the account routes through forwardOpenAIPassthrough.
- func newAstraOAuthSetup(t *testing.T, passthrough bool) *astraForwardSetup {
- t.Helper()
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
- upstream := &httpUpstreamRecorder{}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 888,
- Name: "oauth-astra",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
- Extra: map[string]any{"openai_passthrough": passthrough},
- Status: StatusActive,
- Schedulable: true,
- RateMultiplier: f64p(1),
- }
- return &astraForwardSetup{upstream: upstream, svc: svc, c: c, rec: rec, account: account}
- }
- func codexCompletedSSE(inner string) string {
- return "data: {\"type\":\"response.completed\",\"response\":" + inner + "}\n\ndata: [DONE]\n\n"
- }
- func astraRequestBody(model string, stream bool, mode, effort string) []byte {
- var reasoningParts []string
- if mode != "" {
- reasoningParts = append(reasoningParts, `"mode":"`+mode+`"`)
- }
- if effort != "" {
- reasoningParts = append(reasoningParts, `"effort":"`+effort+`"`)
- }
- reasoning := ""
- if len(reasoningParts) > 0 {
- reasoning = `,"reasoning":{` + strings.Join(reasoningParts, ",") + `}`
- }
- streamJSON := "false"
- if stream {
- streamJSON = "true"
- }
- return []byte(`{"model":"` + model + `","stream":` + streamJSON + `,` +
- `"instructions":"test","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]` +
- reasoning + `}`)
- }
- // TestForward_AstraOAuth_NonAstraLegacyStillStrips asserts the historical strip
- // behavior is preserved for a non-Astra model on an OAuth account.
- func TestForward_AstraOAuth_NonAstraLegacyStillStrips(t *testing.T) {
- gin.SetMode(gin.TestMode)
- s := newAstraOAuthSetup(t, false)
- inner := `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
- s.upstream.resp = &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
- }
- body := astraRequestBody("gpt-5.6-sol", true, "pro", "")
- result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, s.upstream.lastReq)
- require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String())
- forwarded := s.upstream.lastBody
- require.False(t, gjson.GetBytes(forwarded, "reasoning.mode").Exists(),
- "non-Astra legacy must strip reasoning.mode")
- require.Equal(t, "max", gjson.GetBytes(forwarded, "reasoning.effort").String(),
- "non-Astra legacy mode=pro without effort injects max")
- }
- // TestForward_AstraOAuth_ModeMatrix_Preserved runs the pro/standard/missing-mode
- // x effort=max matrix across both forward branches and both client stream modes.
- // Every attempt receives an upstream SSE fixture; assertions check the final
- // upstream request keeps mode/effort and the URL is the Codex responses endpoint.
- func TestForward_AstraOAuth_ModeMatrix_Preserved(t *testing.T) {
- cases := []struct {
- name string
- mode string
- effort string
- wantMod string // gjson string value; "" == absent
- wantEff string
- }{
- {name: "pro+max", mode: "pro", effort: "max", wantMod: "pro", wantEff: "max"},
- {name: "standard+max", mode: "standard", effort: "max", wantMod: "standard", wantEff: "max"},
- {name: "missing mode + max", mode: "", effort: "max", wantMod: "", wantEff: "max"},
- {name: "pro+high", mode: "pro", effort: "high", wantMod: "pro", wantEff: "high"},
- {name: "pro no effort", mode: "pro", effort: "", wantMod: "pro", wantEff: ""},
- }
- for _, passthrough := range []bool{false, true} {
- branch := "native-codex"
- if passthrough {
- branch = "passthrough"
- }
- for _, stream := range []bool{true, false} {
- for _, tt := range cases {
- t.Run(tt.name+"/"+branch+"/stream="+map[bool]string{true: "true", false: "false"}[stream], func(t *testing.T) {
- s := newAstraOAuthSetup(t, passthrough)
- inner := `{"id":"resp_test","model":"gpt-6-astra","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
- s.upstream.resp = &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
- }
- body := astraRequestBody("gpt-6-astra", stream, tt.mode, tt.effort)
- result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, s.upstream.lastReq)
- require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String(),
- "OAuth account must hit the Codex responses endpoint")
- forwarded := s.upstream.lastBody
- require.Equal(t, "gpt-6-astra", gjson.GetBytes(forwarded, "model").String())
- mode := gjson.GetBytes(forwarded, "reasoning.mode")
- if tt.wantMod == "" {
- require.False(t, mode.Exists(), "mode should be absent for %q", tt.name)
- } else {
- require.True(t, mode.Exists(), "Astra mode must be preserved for %q", tt.name)
- require.Equal(t, tt.wantMod, mode.String())
- }
- eff := gjson.GetBytes(forwarded, "reasoning.effort")
- if tt.wantEff == "" {
- require.False(t, eff.Exists(), "no effort injected when omitted for %q", tt.name)
- } else {
- require.Equal(t, tt.wantEff, eff.String(), "Astra effort preserved for %q", tt.name)
- }
- })
- }
- }
- }
- }
- // TestForward_AstraOAuth_NonStreamAggregation_PreservesResponseReasoningMode
- // drives client stream=false (which is forced to an SSE upstream) and asserts the
- // aggregated JSON response object still carries response.reasoning.mode after
- // SSE->JSON aggregation.
- func TestForward_AstraOAuth_NonStreamAggregation_PreservesResponseReasoningMode(t *testing.T) {
- gin.SetMode(gin.TestMode)
- s := newAstraOAuthSetup(t, false)
- // response.completed.response carries a top-level reasoning:{mode:pro,effort:max}.
- 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}}`
- s.upstream.resp = &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
- }
- body := astraRequestBody("gpt-6-astra", false, "pro", "max")
- result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- downstream := s.rec.Body.String()
- // Downstream is JSON (SSE->JSON aggregation for the non-streaming client).
- require.True(t, gjson.Valid(downstream), "downstream must be aggregated JSON, got: %s", downstream)
- require.Equal(t, "pro", gjson.Get(downstream, "reasoning.mode").String(),
- "aggregated response object must keep reasoning.mode")
- require.Equal(t, "max", gjson.Get(downstream, "reasoning.effort").String())
- }
- // TestForward_AstraOAuth_Stream_PreservesResponseReasoningMode drives client
- // stream=true and asserts the streamed response.completed event's response object
- // still carries response.reasoning.mode.
- func TestForward_AstraOAuth_Stream_PreservesResponseReasoningMode(t *testing.T) {
- gin.SetMode(gin.TestMode)
- s := newAstraOAuthSetup(t, false)
- 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}}`
- s.upstream.resp = &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))),
- }
- body := astraRequestBody("gpt-6-astra", true, "pro", "max")
- result, err := s.svc.Forward(context.Background(), s.c, s.account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- downstream := s.rec.Body.String()
- require.Contains(t, downstream, "response.completed", "streaming downstream must include completed event")
- // Extract response.completed line and assert response.reasoning.mode inside it.
- var completedJSON string
- for _, line := range strings.Split(downstream, "\n") {
- if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"response.completed"`) {
- completedJSON = strings.TrimPrefix(line, "data: ")
- break
- }
- }
- require.NotEmpty(t, completedJSON, "no response.completed data line found in: %s", downstream)
- require.Equal(t, "pro", gjson.Get(completedJSON, "response.reasoning.mode").String(),
- "streamed completed response object must keep reasoning.mode")
- require.Equal(t, "max", gjson.Get(completedJSON, "response.reasoning.effort").String())
- }
|