From dfdbc27709a866fdfc2667b720f72ccde3f9f9f7 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Fri, 31 Jul 2026 18:55:15 +0800 Subject: [PATCH] fix(openai): default missing passthrough instructions --- .../service/openai_gateway_passthrough.go | 7 ++ .../service/openai_gateway_request_body.go | 9 +- .../service/openai_oauth_passthrough_test.go | 88 +++++++++---------- .../openai_passthrough_normalization_test.go | 17 ++++ 4 files changed, 72 insertions(+), 49 deletions(-) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index d440c27766..88af2c307f 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -63,6 +63,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( }) return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason) } + if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() { + nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel)) + if setErr != nil { + return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr) + } + body = nextBody + } normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c)) if err != nil { diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 5f23cd1578..5fdcda3698 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -777,14 +777,13 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo } func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string { - model := strings.ToLower(strings.TrimSpace(reqModel)) - if !strings.Contains(model, "codex") { + if !isOpenAICodexModel(reqModel) { return "" } instructions := gjson.GetBytes(body, "instructions") if !instructions.Exists() { - return "instructions_missing" + return "" } if instructions.Type != gjson.String { return "instructions_not_string" @@ -795,6 +794,10 @@ func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byt return "" } +func isOpenAICodexModel(model string) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(model)), "codex") +} + // extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel, // billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个 // 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的 diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 43f8109f88..028f02eb82 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -727,57 +727,53 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamRequestIgnoresClientCance require.NoError(t, upstream.lastReq.Context().Err()) } -func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) { +func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsGetsDefault(t *testing.T) { gin.SetMode(gin.TestMode) - logSink, restore := captureStructuredLog(t) - defer restore() - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil)) - c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown") - c.Request.Header.Set("Content-Type", "application/json") - c.Request.Header.Set("OpenAI-Beta", "responses=experimental") + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + path := "/v1/responses" + responseBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", "data: [DONE]", "", + }, "\n") + responseContentType := "text/event-stream" + if !stream { + path = "/v1/responses/compact" + responseBody = `{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}` + responseContentType = "application/json" + } + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0") - // Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。 - originalBody := []byte(`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`) + originalBody := []byte(fmt.Sprintf(`{"model":"gpt-5.1-codex-max","stream":%t,"store":true,"input":[{"type":"text","text":"hi"}]}`, stream)) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{responseContentType}, "x-request-id": []string{"rid"}}, + Body: io.NopCloser(strings.NewReader(responseBody)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 123, Name: "acc", 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": true, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff}, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } - upstream := &httpUpstreamRecorder{ - resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}}, - Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)), - }, + result, err := svc.Forward(context.Background(), c, account, originalBody) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + if stream { + require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) + } else { + require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists()) + } + require.Equal(t, strings.TrimSpace(defaultCodexSynthInstructions("gpt-5.1-codex-max")), strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String())) + }) } - - svc := &OpenAIGatewayService{ - cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, - httpUpstream: upstream, - } - - account := &Account{ - ID: 123, - Name: "acc", - 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": true}, - Status: StatusActive, - Schedulable: true, - RateMultiplier: f64p(1), - } - - result, err := svc.Forward(context.Background(), c, account, originalBody) - require.Error(t, err) - require.Nil(t, result) - require.Equal(t, http.StatusForbidden, rec.Code) - require.Contains(t, rec.Body.String(), "requires a non-empty instructions field") - require.Nil(t, upstream.lastReq) - - require.True(t, logSink.ContainsMessage("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")) - require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")) - require.True(t, logSink.ContainsFieldValue("reject_reason", "instructions_missing")) } func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) { diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index 0212c3e1f8..15b803a7de 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -85,3 +85,20 @@ func TestNormalizeOpenAIPassthroughOAuthBody_ArrayInputUnchanged(t *testing.T) { require.Len(t, input.Array(), 1) require.Equal(t, "message", input.Array()[0].Get("type").String()) } + +func TestDetectOpenAIPassthroughInstructionsRejectReason(t *testing.T) { + for _, tt := range []struct { + name string + body string + want string + }{ + {name: "missing is optional", body: `{"model":"gpt-5.1-codex"}`, want: ""}, + {name: "non string remains rejected", body: `{"instructions":{"text":"invalid"}}`, want: "instructions_not_string"}, + {name: "empty remains rejected", body: `{"instructions":" "}`, want: "instructions_empty"}, + {name: "non empty remains accepted", body: `{"instructions":"client guidance"}`, want: ""}, + } { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, detectOpenAIPassthroughInstructionsRejectReason("gpt-5.1-codex", []byte(tt.body))) + }) + } +}