From 0aaed397cc62cca9d954ba33e1715725cdbeccf7 Mon Sep 17 00:00:00 2001 From: alfadb Date: Sun, 6 Sep 2026 09:44:31 +0800 Subject: [PATCH] fix: preserve Astra reasoning mode for Codex OAuth --- ...penai_responses_astra_pro_failover_test.go | 205 +++++++++++++++ .../service/openai_astra_pro_mode_test.go | 244 ++++++++++++++++++ .../service/openai_astra_pro_retry_test.go | 92 +++++++ .../service/openai_gateway_request_body.go | 4 + .../openai_passthrough_normalization_test.go | 63 +++++ 5 files changed, 608 insertions(+) create mode 100644 backend/internal/handler/openai_responses_astra_pro_failover_test.go create mode 100644 backend/internal/service/openai_astra_pro_mode_test.go create mode 100644 backend/internal/service/openai_astra_pro_retry_test.go diff --git a/backend/internal/handler/openai_responses_astra_pro_failover_test.go b/backend/internal/handler/openai_responses_astra_pro_failover_test.go new file mode 100644 index 0000000000..06bc79b3cd --- /dev/null +++ b/backend/internal/handler/openai_responses_astra_pro_failover_test.go @@ -0,0 +1,205 @@ +//go:build unit + +package handler + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// astraProRequestBody is the inbound OpenAI Responses request that must survive +// the account-switch loop unchanged: gpt-6-astra with the official top-level +// reasoning.mode/effort preserved by the service guard +// (normalizeOpenAIResponsesReasoningMode), never downgraded mode -> effort. +const astraProRequestBody = `{"model":"gpt-6-astra","stream":false,"input":"hello","reasoning":{"mode":"pro","effort":"max"}}` + +// astraProCodexURL is the OAuth codex Responses exit every forwarded attempt must hit. +const astraProCodexURL = "https://chatgpt.com/backend-api/codex/responses" + +// astraProSuccessResponse is the winning account's non-stream success; it echoes +// the structured reasoning.mode=pro so the client response must keep mode=pro. +const astraProSuccessResponse = `{"id":"resp_astra","model":"gpt-6-astra","status":"completed",` + + `"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],` + + `"usage":{"input_tokens":1,"output_tokens":2},` + + `"reasoning":{"mode":"pro","effort":"max"}}` + +// astraProForbiddenResponse: a plain upstream 403 that stays failover-eligible but +// is not reclassified as access-state/credential failure nor a silent refusal. +const astraProForbiddenResponse = `{"error":{"type":"forbidden_error","code":"forbidden","message":"Forbidden"}}` + +// astraProCapturedUpstream records URL/accountID/body per forwarded attempt. +type astraProCapturedUpstream struct { + service.HTTPUpstream + mu sync.Mutex + urls []string + accountIDs []int64 + bodies [][]byte + answers []*http.Response +} + +func newAstraProCapturedUpstream(answers ...*http.Response) *astraProCapturedUpstream { + return &astraProCapturedUpstream{answers: answers} +} + +func (u *astraProCapturedUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) { + u.mu.Lock() + defer u.mu.Unlock() + body, _ := io.ReadAll(req.Body) + u.urls = append(u.urls, req.URL.String()) + u.accountIDs = append(u.accountIDs, accountID) + u.bodies = append(u.bodies, body) + var resp *http.Response + if len(u.answers) > 0 { + resp = u.answers[0] + u.answers = u.answers[1:] + } + if resp == nil { + resp = &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil))} + } + return resp, nil +} + +func (u *astraProCapturedUpstream) snapshot() ([]string, []int64, [][]byte) { + u.mu.Lock() + defer u.mu.Unlock() + return append([]string(nil), u.urls...), append([]int64(nil), u.accountIDs...), append([][]byte(nil), u.bodies...) +} + +// newAstraProFailoverContext builds the gin context that drives handler.Responses +// through the real failover loop for the astra pro request. +func newAstraProFailoverContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + groupID := int64(3132) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 99, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, + User: &service.User{ID: 100}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0}) + return c, rec +} + +func astra403() *http.Response { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(astraProForbiddenResponse)), + } +} + +func astra200() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(astraProSuccessResponse)), + } +} + +// assertAstraProWire: every forwarded attempt must hit the codex responses URL and +// keep model=gpt-6-astra + reasoning.mode=pro + effort=max (no silent downgrade). +func assertAstraProWire(t *testing.T, urls []string, bodies [][]byte) { + t.Helper() + require.NotEmpty(t, urls, "failover loop must reach the upstream transport") + for i, u := range urls { + require.Equal(t, astraProCodexURL, u, "attempt %d must target the codex responses URL", i) + } + for i, body := range bodies { + require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String(), "attempt %d must keep model", i) + require.Equal(t, "pro", gjson.GetBytes(body, "reasoning.mode").String(), "attempt %d must keep mode=pro", i) + require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String(), "attempt %d must keep effort=max", i) + } +} + +// assertAstraProAccountSwitch: the failover loop must have actually touched both +// distinct OAuth fixture accounts (IDs 1 and 2), not replayed the same one. +func assertAstraProAccountSwitch(t *testing.T, accountIDs []int64) { + t.Helper() + require.Len(t, accountIDs, 2, "failover must attempt two accounts") + require.ElementsMatch(t, []int64{1, 2}, accountIDs, "both OAuth accounts must be reached") +} + +// 403 then a second OAuth account succeeding: wire bodies keep pro+max/model and the +// winning account's structured reasoning.mode=pro reaches the client unchanged. +func TestOpenAIGatewayHandlerResponses_AstraProFirst403SecondSucceeds(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := newAstraProCapturedUpstream(astra403(), astra200()) + handler := newOpenAIResponsesFailoverTestHandler(t, upstream) + c, rec := newAstraProFailoverContext(t, astraProRequestBody) + + handler.Responses(c) + + urls, accountIDs, bodies := upstream.snapshot() + assertAstraProWire(t, urls, bodies) + assertAstraProAccountSwitch(t, accountIDs) + require.Equal(t, http.StatusOK, rec.Code, "winning account must yield a 200 to the client") + require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "status").String()) + require.Equal(t, "pro", gjson.GetBytes(rec.Body.Bytes(), "reasoning.mode").String(), "client must keep the winning response's mode=pro") + require.Equal(t, "max", gjson.GetBytes(rec.Body.Bytes(), "reasoning.effort").String()) +} + +// both OAuth accounts 403: exhausted keeps every wire body pro+max/model, is not 200, +// and surfaces the existing-policy 502 upstream_error (unchanged 403-masking policy). +func TestOpenAIGatewayHandlerResponses_AstraProBoth403NoDowngrade(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := newAstraProCapturedUpstream(astra403(), astra403()) + handler := newOpenAIResponsesFailoverTestHandler(t, upstream) + c, rec := newAstraProFailoverContext(t, astraProRequestBody) + + handler.Responses(c) + + urls, accountIDs, bodies := upstream.snapshot() + assertAstraProWire(t, urls, bodies) + assertAstraProAccountSwitch(t, accountIDs) + require.NotEqual(t, http.StatusOK, rec.Code, "all-403 must never surface a 200 to the client") + require.Equal(t, http.StatusBadGateway, rec.Code) + require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) +} + +// passthrough -> non-passthrough drops only the encrypted *input* reasoning item; +// the official top-level reasoning.mode/effort survive verbatim. +func TestDeriveOpenAIForwardAttemptBody_AstraCrossModeKeepsTopLevelReasoningMode(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(`{"model":"gpt-6-astra","stream":false,"reasoning":{"mode":"pro","effort":"max"},"input":[` + + `{"type":"message","role":"user","content":"hello"},` + + `{"type":"reasoning","id":"rs_kiro_abc","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]}` + + `]}`) + canonicalSnapshot := append([]byte(nil), canonical...) + state := &openAIPassthroughFailoverState{} + + kiro := newOpenAIPassthroughAccount(1, true) // passthrough first + bedrock := newOpenAIPassthroughAccount(2, false) // non-passthrough after + + firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt keeps the encrypted input reasoning item") + require.Equal(t, "pro", gjson.GetBytes(firstBody, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(firstBody, "reasoning.effort").String()) + + secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state) + require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt drops the encrypted input reasoning item") + require.Equal(t, "pro", gjson.GetBytes(secondBody, "reasoning.mode").String(), "mode=pro must survive cross-mode sanitization") + require.Equal(t, "max", gjson.GetBytes(secondBody, "reasoning.effort").String(), "effort=max must survive cross-mode sanitization") + require.Equal(t, "gpt-6-astra", gjson.GetBytes(secondBody, "model").String()) + require.JSONEq(t, string(canonicalSnapshot), string(canonical), "canonical forwardBody must never be mutated") + require.True(t, gjson.GetBytes(canonical, "input.1.encrypted_content").Exists()) +} diff --git a/backend/internal/service/openai_astra_pro_mode_test.go b/backend/internal/service/openai_astra_pro_mode_test.go new file mode 100644 index 0000000000..945d3a2542 --- /dev/null +++ b/backend/internal/service/openai_astra_pro_mode_test.go @@ -0,0 +1,244 @@ +//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()) +} diff --git a/backend/internal/service/openai_astra_pro_retry_test.go b/backend/internal/service/openai_astra_pro_retry_test.go new file mode 100644 index 0000000000..246884b674 --- /dev/null +++ b/backend/internal/service/openai_astra_pro_retry_test.go @@ -0,0 +1,92 @@ +package service + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const openAICodexResponsesTestURL = "https://chatgpt.com/backend-api/codex/responses" + +// newOpenAIAstraProRetryContext returns a gin context plus its recorder so the +// caller can assert the service-layer client error shape (type/code/param/message). +func newOpenAIAstraProRetryContext(body []byte) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, recorder +} + +// TestOpenAIGatewayService_OAuthAstraProModeRejectionPassesThrough verifies that +// when the Codex OAuth upstream rejects an Astra pro+max request with a +// deterministic HTTP 400 on error.param=reasoning.mode, the gateway passes the +// client error through unchanged (upstream type/code/param/message, code not +// treated as official), makes exactly one upstream call, keeps reasoning.mode=pro +// and reasoning.effort=max in the sent body, and does not replay without mode. +func TestOpenAIGatewayService_OAuthAstraProModeRejectionPassesThrough(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"input":"hello"}`) + upstream := &httpUpstreamRecorder{resp: newOpenAIRejectedFieldTestResponse( + http.StatusBadRequest, + `{"error":{"type":"invalid_request_error","code":"model_specific_rejection","message":"reasoning.mode is not supported for this model","param":"reasoning.mode"}}`, + )} + svc := newOpenAIRejectedFieldTestService(upstream) + c, recorder := newOpenAIAstraProRetryContext(body) + + _, err := svc.Forward(context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Len(t, upstream.bodies, 1, "deterministic 400 must not trigger a retry") + require.Len(t, upstream.requests, 1) + require.Equal(t, openAICodexResponsesTestURL, upstream.requests[0].URL.String()) + + sent := upstream.bodies[0] + require.Equal(t, "gpt-6-astra", gjson.GetBytes(sent, "model").String()) + require.Equal(t, "pro", gjson.GetBytes(sent, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(sent, "reasoning.effort").String()) + + respJSON := recorder.Body.String() + require.Equal(t, "invalid_request_error", gjson.Get(respJSON, "error.type").String()) + require.Equal(t, "model_specific_rejection", gjson.Get(respJSON, "error.code").String()) + require.Equal(t, "reasoning.mode", gjson.Get(respJSON, "error.param").String()) + require.Equal(t, "reasoning.mode is not supported for this model", gjson.Get(respJSON, "error.message").String()) +} + +// TestOpenAIGatewayService_OAuthAstraProModeKeptAcrossRejectedFieldRetry reuses +// the existing OAuth rejected-field retry fixture (input[0].status is rejected, +// stripped, then replayed to success) to confirm Astra pro+max reasoning is kept +// across every in-service retry: each captured Codex URL request body still +// carries reasoning.mode=pro and reasoning.effort=max with model=gpt-6-astra, +// and only the second attempt drops the rejected status field. +func TestOpenAIGatewayService_OAuthAstraProModeKeptAcrossRejectedFieldRetry(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","stream":true,"instructions":"test","reasoning":{"mode":"pro","effort":"max"},"input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`), + 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"), + }} + upstream.responses[1].Header.Set("Content-Type", "text/event-stream") + svc := newOpenAIRejectedFieldTestService(upstream) + c, _ := newOpenAIAstraProRetryContext(body) + + result, err := svc.Forward(context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Len(t, upstream.requests, 2) + for i := range upstream.bodies { + require.Equal(t, openAICodexResponsesTestURL, upstream.requests[i].URL.String(), "attempt %d URL", i) + sent := upstream.bodies[i] + require.Equal(t, "gpt-6-astra", gjson.GetBytes(sent, "model").String(), "attempt %d model", i) + require.Equal(t, "pro", gjson.GetBytes(sent, "reasoning.mode").String(), "attempt %d reasoning.mode", i) + require.Equal(t, "max", gjson.GetBytes(sent, "reasoning.effort").String(), "attempt %d reasoning.effort", i) + } + require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists()) +} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 9defec9e65..af4825762b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1023,6 +1023,10 @@ func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } + // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。 + if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) { + return body, false, nil + } mode := gjson.GetBytes(body, "reasoning.mode") if !mode.Exists() || mode.Type != gjson.String { return body, false, nil diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index f86ecc6927..a05bee25ad 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -138,6 +138,69 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { } } +func TestNormalizeOpenAIResponsesReasoningMode_AstraPreservesBody(t *testing.T) { + // GPT-6 Astra 保留官方 reasoning.mode 与 reasoning.effort 各自原样: + // 不删 mode、缺失 effort 也不补 max。非 Astra 的旧兼容行为不受影响。 + tests := []struct { + name string + body string + }{ + {name: "pro + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`}, + {name: "standard + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"standard","effort":"max"}}`}, + {name: "missing mode + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"effort":"max"}}`}, + {name: "pro + explicit high preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"high"}}`}, + {name: "pro without effort does not inject max", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro"}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + require.NoError(t, err) + require.False(t, changed) + require.JSONEq(t, tt.body, string(normalized)) + }) + } +} + +func TestNormalizeOpenAIResponsesReasoningMode_NonAstraKeepsLegacyBehavior(t *testing.T) { + // 确保 guard 只放过 Astra,非 Astra model 字段不影响既有 strip/pro->max 语义。 + tests := []struct { + name string + body string + wantEffort string + }{ + {name: "gpt-5.6-sol pro maps to max", body: `{"model":"gpt-5.6-sol","reasoning":{"mode":"pro"}}`, wantEffort: "max"}, + {name: "gpt-5.4 explicit effort wins", body: `{"model":"gpt-5.4","reasoning":{"mode":"pro","effort":"high"}}`, wantEffort: "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) + require.Equal(t, tt.wantEffort, gjson.GetBytes(normalized, "reasoning.effort").String()) + }) + } +} + +func TestNormalizeOpenAIPassthroughOAuthBody_AstraPreservesReasoningMode(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`) + + normalized, _, err := normalizeOpenAIPassthroughOAuthBody(body, false) + require.NoError(t, err) + require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_AstraPreservesReasoningMode(t *testing.T) { + body := []byte(`{"type":"response.create","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`) + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) + require.NoError(t, err) + require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) + } +} + func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) { body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`) for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {