From 92edda2f6e2d239b02c312787d9e7eb3c57fd35c Mon Sep 17 00:00:00 2001 From: jjaw Date: Tue, 14 Jul 2026 21:57:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=84=8F=E5=9B=BE=E5=88=A4=E5=AE=9A=E5=A4=8D?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_gateway_passthrough.go | 5 +- ...passthrough_image_intent_benchmark_test.go | 27 +++++++++ ...i_gateway_passthrough_image_intent_test.go | 59 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 backend/internal/service/openai_gateway_passthrough_image_intent_benchmark_test.go create mode 100644 backend/internal/service/openai_gateway_passthrough_image_intent_test.go diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 0a19a9d283..8d8c6c96fa 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -100,7 +100,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = updatedBody apiKey := getAPIKeyFromContext(c) - if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) { + imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusForbidden, gin.H{ "error": gin.H{ @@ -113,7 +114,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( imageBillingModel := "" imageSizeTier := "" imageInputSize := "" - if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) { + if imageIntent { var imageCfgErr error imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel) if imageCfgErr != nil { diff --git a/backend/internal/service/openai_gateway_passthrough_image_intent_benchmark_test.go b/backend/internal/service/openai_gateway_passthrough_image_intent_benchmark_test.go new file mode 100644 index 0000000000..9335a4e435 --- /dev/null +++ b/backend/internal/service/openai_gateway_passthrough_image_intent_benchmark_test.go @@ -0,0 +1,27 @@ +package service + +import "testing" + +var passthroughImageIntentBenchmarkSink bool + +func BenchmarkOpenAIPassthroughImageIntentReuse_LargeBody(b *testing.B) { + body := buildLargeOpenAIResponsesImageToolBody(32 << 20) + + b.Run("Once", func(b *testing.B) { + b.SetBytes(int64(len(body))) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + passthroughImageIntentBenchmarkSink = IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body) + } + }) + + b.Run("Twice", func(b *testing.B) { + b.SetBytes(int64(len(body))) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + permissionIntent := IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body) + billingIntent := IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body) + passthroughImageIntentBenchmarkSink = permissionIntent && billingIntent + } + }) +} diff --git a/backend/internal/service/openai_gateway_passthrough_image_intent_test.go b/backend/internal/service/openai_gateway_passthrough_image_intent_test.go new file mode 100644 index 0000000000..1720ef6e13 --- /dev/null +++ b/backend/internal/service/openai_gateway_passthrough_image_intent_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIGatewayService_APIKeyPassthrough_ImageIntentPreservesGateAndBilling(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-5.4","stream":false,"tools":[{"type":"image_generation","model":"gpt-image-2","size":"2048x1152"}],"input":"draw"}`) + + t.Run("disabled group rejects before upstream", func(t *testing.T) { + upstream := &httpUpstreamRecorder{} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(false, "curl/8.0") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + result, err := svc.Forward(context.Background(), c, account, body) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Equal(t, "permission_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + require.Nil(t, upstream.lastReq) + }) + + t.Run("allowed group keeps image billing", func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-image","size":"2048x1152"}],"usage":{"input_tokens":1,"output_tokens":2}}`, + )), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "curl/8.0") + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, body, upstream.lastBody) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, "gpt-image-2", result.BillingModel) + require.Equal(t, "2K", result.ImageSize) + require.Equal(t, "2048x1152", result.ImageInputSize) + }) +} From 5e43c7faac3227baebea5d8a421506c00b0906a6 Mon Sep 17 00:00:00 2001 From: jjaw Date: Wed, 15 Jul 2026 00:00:40 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E6=B5=81=E4=BA=8B=E4=BB=B6=E8=BE=B9=E7=95=8C=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/openai_gateway_passthrough.go | 14 +- .../openai_gateway_passthrough_flush_test.go | 284 ++++++++++++++++++ 2 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_gateway_passthrough_flush_test.go diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 8d8c6c96fa..5dc0d180bc 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -864,6 +864,15 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) pendingLines := make([]string, 0, 8) + flushPending := false + flushPendingOutput := func() { + if clientDisconnected || !flushPending { + return + } + flusher.Flush() + flushPending = false + } + defer flushPendingOutput() writePendingLines := func() bool { for _, pending := range pendingLines { if _, err := fmt.Fprintln(w, pending); err != nil { @@ -1013,7 +1022,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID) } else { clientOutputStarted = true - flusher.Flush() + flushPending = true + if line == "" { + flushPendingOutput() + } } } } diff --git a/backend/internal/service/openai_gateway_passthrough_flush_test.go b/backend/internal/service/openai_gateway_passthrough_flush_test.go new file mode 100644 index 0000000000..b36a037806 --- /dev/null +++ b/backend/internal/service/openai_gateway_passthrough_flush_test.go @@ -0,0 +1,284 @@ +package service + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type passthroughFlushTestWriter struct { + gin.ResponseWriter + recorder *httptest.ResponseRecorder + failAfterWrites int + successfulWrites int + failedWrites int + flushBodyLengths []int +} + +func (w *passthroughFlushTestWriter) Write(data []byte) (int, error) { + if w.failAfterWrites >= 0 && w.successfulWrites >= w.failAfterWrites { + w.failedWrites++ + return 0, errors.New("client disconnected") + } + n, err := w.ResponseWriter.Write(data) + if err == nil { + w.successfulWrites++ + } + return n, err +} + +func (w *passthroughFlushTestWriter) WriteString(data string) (int, error) { + return w.Write([]byte(data)) +} + +func (w *passthroughFlushTestWriter) Flush() { + w.ResponseWriter.Flush() + w.flushBodyLengths = append(w.flushBodyLengths, w.recorder.Body.Len()) +} + +type passthroughFlushTestErrorBody struct { + payload []byte + err error + sent bool +} + +func (r *passthroughFlushTestErrorBody) Read(p []byte) (int, error) { + if !r.sent { + r.sent = true + return copy(p, r.payload), nil + } + return 0, r.err +} + +func (r *passthroughFlushTestErrorBody) Close() error { return nil } + +func runPassthroughFlushTest( + t *testing.T, + body io.ReadCloser, + failAfterWrites int, + setups ...func(*gin.Context), +) (*openaiStreamingResultPassthrough, *httptest.ResponseRecorder, *passthroughFlushTestWriter, error) { + t.Helper() + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + writer := &passthroughFlushTestWriter{ + ResponseWriter: c.Writer, + recorder: recorder, + failAfterWrites: failAfterWrites, + } + c.Writer = writer + for _, setup := range setups { + setup(c) + } + + svc := &OpenAIGatewayService{cfg: &config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + }} + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + } + result, err := svc.handleStreamingResponsePassthrough( + context.Background(), + resp, + c, + &Account{ID: 1, Platform: PlatformOpenAI, Name: "flush-test"}, + time.Now(), + "", + "", + ) + return result, recorder, writer, err +} + +func TestOpenAIStreamingPassthroughFlushesAtCompleteEventBoundaries(t *testing.T) { + firstEvent := "event: response.output_text.delta\n" + + "id: event-1\n" + + `data: {"type":"response.output_text.delta","delta":"hello"}` + "\n\n" + heartbeat := ": keepalive\n\n" + terminalEvent := "event: response.completed\n" + + `data: {"type":"response.completed","response":{"id":"resp_flush","usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}` + "\n\n" + upstream := firstEvent + heartbeat + terminalEvent + + result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, upstream, recorder.Body.String()) + require.Equal(t, []int{ + len(firstEvent), + len(firstEvent) + len(heartbeat), + len(upstream), + }, writer.flushBodyLengths) + require.Equal(t, 3, result.usage.InputTokens) + require.Equal(t, 2, result.usage.OutputTokens) +} + +func TestOpenAIStreamingPassthroughKeepsPreamblePendingUntilFirstOutputBoundary(t *testing.T) { + preamble := "event: response.created\n" + + `data: {"type":"response.created","response":{"id":"resp_pending"}}` + "\n\n" + + ": waiting\n\n" + firstOutput := `data: {"type":"response.output_text.delta","delta":"ready"}` + "\n\n" + terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_pending","usage":{"input_tokens":4,"output_tokens":1,"total_tokens":5}}}` + "\n\n" + upstream := preamble + firstOutput + terminalEvent + + _, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.NoError(t, err) + require.Equal(t, upstream, recorder.Body.String()) + require.Equal(t, []int{ + len(preamble) + len(firstOutput), + len(upstream), + }, writer.flushBodyLengths) +} + +func TestOpenAIStreamingPassthroughFlushesTerminalEventAtEOFWithoutBlankLine(t *testing.T) { + upstream := "event: response.completed\n" + + `data: {"type":"response.completed","response":{"id":"resp_eof","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}` + wantBody := upstream + "\n" + + result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, wantBody, recorder.Body.String()) + require.Equal(t, []int{len(wantBody)}, writer.flushBodyLengths) + require.Equal(t, 5, result.usage.InputTokens) + require.Equal(t, 2, result.usage.OutputTokens) +} + +func TestOpenAIStreamingPassthroughFailedBeforeOutputCanStillFailOverWithoutFlush(t *testing.T) { + upstream := "event: response.created\n" + + `data: {"type":"response.created","response":{"id":"resp_failover"}}` + "\n\n" + + "event: response.failed\n" + + `data: {"type":"response.failed","error":{"code":"server_error","message":"upstream processing failed"}}` + "\n\n" + + _, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.ErrorAs(t, err, &failoverErr) + require.Empty(t, recorder.Body.String()) + require.Empty(t, writer.flushBodyLengths) +} + +func TestOpenAIStreamingPassthroughNonRetryableFailedBeforeOutputFlushesAtBoundary(t *testing.T) { + upstream := "event: response.failed\n" + + `data: {"type":"response.failed","error":{"code":"content_policy","message":"request blocked by policy"},"usage":{"input_tokens":6,"output_tokens":0,"total_tokens":6}}` + "\n\n" + + result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.NotNil(t, result) + require.Equal(t, upstream, recorder.Body.String()) + require.Equal(t, []int{len(upstream)}, writer.flushBodyLengths) + require.Equal(t, 6, result.usage.InputTokens) + require.Zero(t, result.usage.OutputTokens) +} + +func TestOpenAIStreamingPassthroughFailedAfterOutputFlushesAtBoundaryAndKeepsUsage(t *testing.T) { + firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n" + failedEvent := "event: response.failed\n" + + `data: {"type":"response.failed","error":{"code":"server_error","message":"upstream processing failed"},"usage":{"input_tokens":7,"output_tokens":2,"total_tokens":9}}` + "\n\n" + upstream := firstOutput + failedEvent + + result, recorder, writer, err := runPassthroughFlushTest(t, io.NopCloser(strings.NewReader(upstream)), -1) + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.NotNil(t, result) + require.Equal(t, upstream, recorder.Body.String()) + require.Equal(t, []int{len(firstOutput), len(upstream)}, writer.flushBodyLengths) + require.Equal(t, 7, result.usage.InputTokens) + require.Equal(t, 2, result.usage.OutputTokens) +} + +func TestOpenAIStreamingPassthroughClientDisconnectStillDrainsTerminalUsage(t *testing.T) { + firstOutput := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n\n" + terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_drain","usage":{"input_tokens":11,"output_tokens":4,"total_tokens":15}}}` + "\n\n" + + result, recorder, writer, err := runPassthroughFlushTest( + t, + io.NopCloser(strings.NewReader(firstOutput+terminalEvent)), + 2, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, firstOutput, recorder.Body.String()) + require.Equal(t, []int{len(firstOutput)}, writer.flushBodyLengths) + require.Equal(t, 1, writer.failedWrites) + require.Equal(t, 11, result.usage.InputTokens) + require.Equal(t, 4, result.usage.OutputTokens) +} + +func TestOpenAIStreamingPassthroughScannerErrorFlushesWrittenResidual(t *testing.T) { + upstream := []byte(`data: {"type":"response.output_text.delta","delta":"partial"}`) + readErr := errors.New("upstream read failed") + + _, recorder, writer, err := runPassthroughFlushTest(t, &passthroughFlushTestErrorBody{ + payload: upstream, + err: readErr, + }, -1) + + require.ErrorIs(t, err, readErr) + wantBody := string(upstream) + "\n" + require.Equal(t, wantBody, recorder.Body.String()) + require.Equal(t, []int{len(wantBody)}, writer.flushBodyLengths) +} + +func TestOpenAIStreamingPassthroughNamespaceRestoreErrorFlushesWrittenResidualOnce(t *testing.T) { + writtenPrefix := `data: {"type":"response.output_text.delta","delta":"prefix"}` + "\n" + overflowData := `data: {"type":"response.output_text.delta","delta":"not-written","overflow":1e1000}` + + _, recorder, writer, err := runPassthroughFlushTest( + t, + io.NopCloser(strings.NewReader(writtenPrefix+overflowData)), + -1, + func(c *gin.Context) { + setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{ + "collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"}, + }) + }, + ) + + require.ErrorContains(t, err, "restore OpenAI passthrough namespace response") + require.Equal(t, writtenPrefix, recorder.Body.String()) + require.Equal(t, []int{len(writtenPrefix)}, writer.flushBodyLengths) +} + +func TestOpenAIStreamingPassthroughBlankWriteFailureDoesNotFlushAndStillDrainsUsage(t *testing.T) { + writtenDataLine := `data: {"type":"response.output_text.delta","delta":"partial"}` + "\n" + terminalEvent := `data: {"type":"response.completed","response":{"id":"resp_blank_failure","usage":{"input_tokens":13,"output_tokens":5,"total_tokens":18}}}` + "\n\n" + + result, recorder, writer, err := runPassthroughFlushTest( + t, + io.NopCloser(strings.NewReader(writtenDataLine+"\n"+terminalEvent)), + 1, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, writtenDataLine, recorder.Body.String()) + require.Empty(t, writer.flushBodyLengths) + require.Equal(t, 1, writer.successfulWrites) + require.Equal(t, 1, writer.failedWrites) + require.Equal(t, 13, result.usage.InputTokens) + require.Equal(t, 5, result.usage.OutputTokens) +} From 43a2c13173b5394402f51d465e5672ccd6a11249 Mon Sep 17 00:00:00 2001 From: jjaw Date: Tue, 14 Jul 2026 15:21:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=84=8F=E5=9B=BE=E5=AD=97=E6=AE=B5=E6=89=AB?= =?UTF-8?q?=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/image_generation_intent.go | 40 ++++++++--- .../image_generation_intent_benchmark_test.go | 59 +++++++++++++++ .../service/image_generation_intent_test.go | 72 +++++++++++++++++++ 3 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 backend/internal/service/image_generation_intent_benchmark_test.go diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index 613f11d048..72f3be7db6 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -47,16 +47,36 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte if len(body) == 0 || !gjson.ValidBytes(body) { return false } - if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); isOpenAIImageGenerationModel(model) { - return true - } - if openAIJSONToolsContainImageGeneration(gjson.GetBytes(body, "tools")) { - return true - } - if openAIJSONInputContainsImageGenTool(gjson.GetBytes(body, "input")) { - return true - } - return openAIJSONToolChoiceSelectsImageGeneration(gjson.GetBytes(body, "tool_choice")) + + var modelSeen, toolsSeen, inputSeen, toolChoiceSeen bool + imageIntent := false + parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool { + // GetBytes returns the first duplicate key; retain that behavior while walking the root once. + switch key.Str { + case "model": + if !modelSeen { + modelSeen = true + imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String())) + } + case "tools": + if !toolsSeen { + toolsSeen = true + imageIntent = openAIJSONToolsContainImageGeneration(value) + } + case "input": + if !inputSeen { + inputSeen = true + imageIntent = openAIJSONInputContainsImageGenTool(value) + } + case "tool_choice": + if !toolChoiceSeen { + toolChoiceSeen = true + imageIntent = openAIJSONToolChoiceSelectsImageGeneration(value) + } + } + return !imageIntent && (!modelSeen || !toolsSeen || !inputSeen || !toolChoiceSeen) + }) + return imageIntent } // IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation. diff --git a/backend/internal/service/image_generation_intent_benchmark_test.go b/backend/internal/service/image_generation_intent_benchmark_test.go new file mode 100644 index 0000000000..d0df0c28af --- /dev/null +++ b/backend/internal/service/image_generation_intent_benchmark_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "strings" + "testing" +) + +var imageGenerationIntentBenchmarkResult bool + +func BenchmarkIsImageGenerationIntent(b *testing.B) { + largeInput := strings.Repeat("x", 1<<20) + benchmarks := []struct { + name string + body []byte + want bool + }{ + { + name: "1MiBInputNoImage", + body: []byte(`{"model":"gpt-5.5","tools":[],"input":"` + largeInput + `","tool_choice":"auto"}`), + want: false, + }, + { + name: "1MiBInputLeadingImageTool", + body: []byte(`{"model":"gpt-5.5","tools":[{"type":"image_generation"}],"input":"` + largeInput + `"}`), + want: true, + }, + { + name: "1MiBInputTrailingToolChoice", + body: []byte(`{"model":"gpt-5.5","tools":[],"input":"` + largeInput + `","tool_choice":{"type":"image_generation"}}`), + want: true, + }, + { + name: "Invalid1MiBJSON", + body: []byte(`{"model":"gpt-5.5","input":"` + largeInput), + want: false, + }, + { + name: "DuplicateKeysFirstWins", + body: []byte(`{"model":"gpt-5.5","model":"gpt-image-2","tools":[],"tools":[{"type":"image_generation"}],"input":"` + largeInput + `","tool_choice":"auto","tool_choice":{"type":"image_generation"}}`), + want: false, + }, + } + + for _, benchmark := range benchmarks { + b.Run(benchmark.name, func(b *testing.B) { + if got := IsImageGenerationIntent("/v1/responses", "gpt-5.5", benchmark.body); got != benchmark.want { + b.Fatalf("IsImageGenerationIntent() = %v, want %v", got, benchmark.want) + } + b.ReportAllocs() + b.SetBytes(int64(len(benchmark.body))) + b.ResetTimer() + var result bool + for i := 0; i < b.N; i++ { + result = IsImageGenerationIntent("/v1/responses", "gpt-5.5", benchmark.body) + } + imageGenerationIntentBenchmarkResult = result + }) + } +} diff --git a/backend/internal/service/image_generation_intent_test.go b/backend/internal/service/image_generation_intent_test.go index 7f3ac8a611..c7c7f9db4e 100644 --- a/backend/internal/service/image_generation_intent_test.go +++ b/backend/internal/service/image_generation_intent_test.go @@ -1,6 +1,7 @@ package service import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -106,6 +107,77 @@ func TestIsImageGenerationIntent(t *testing.T) { } } +func TestIsImageGenerationIntentJSONSemantics(t *testing.T) { + largeInput := strings.Repeat("x", 1<<20) + tests := []struct { + name string + endpoint string + body []byte + want bool + }{ + { + name: "chat body image model", + endpoint: "/v1/chat/completions", + body: []byte(`{"model":"gpt-image-2"}`), + want: true, + }, + { + name: "large responses input with trailing namespace tool choice", + endpoint: "/v1/responses", + body: []byte(`{"model":"gpt-5.5","input":"` + largeInput + `","tool_choice":{"type":"namespace","name":"image_gen"}}`), + want: true, + }, + { + name: "invalid json with image tool", + endpoint: "/v1/responses", + body: []byte(`{"tools":[{"type":"image_generation"}]`), + want: false, + }, + { + name: "duplicate model uses first value", + endpoint: "/v1/responses", + body: []byte(`{"model":"gpt-5.5","model":"gpt-image-2"}`), + want: false, + }, + { + name: "duplicate null model still uses first value", + endpoint: "/v1/responses", + body: []byte(`{"model":null,"model":"gpt-image-2"}`), + want: false, + }, + { + name: "duplicate tools uses first value", + endpoint: "/v1/responses", + body: []byte(`{"tools":[],"tools":[{"type":"image_generation"}]}`), + want: false, + }, + { + name: "duplicate input uses first value", + endpoint: "/v1/responses", + body: []byte(`{"input":[],"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen"}]}]}`), + want: false, + }, + { + name: "duplicate tool choice uses first value", + endpoint: "/v1/responses", + body: []byte(`{"tool_choice":"required","tool_choice":{"type":"image_generation"}}`), + want: false, + }, + { + name: "escaped top level key", + endpoint: "/v1/responses", + body: []byte(`{"tool_\u0063hoice":{"type":"image_generation"}}`), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsImageGenerationIntent(tt.endpoint, "gpt-5.5", tt.body)) + }) + } +} + func TestIsImageGenerationIntentMap_NamespaceImageGen(t *testing.T) { tests := []struct { name string From 78e09a0f1a6dfa92ecff9b201f1aaedf9d3a7bcc Mon Sep 17 00:00:00 2001 From: jjaw Date: Wed, 15 Jul 2026 04:20:11 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E7=BA=A7=E5=9B=BE=E7=89=87=E7=94=9F=E6=88=90=E6=84=8F=E5=9B=BE?= =?UTF-8?q?=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/openai_gateway_handler.go | 8 + .../handler/openai_image_intent_hint_test.go | 44 +++ .../service/openai_gateway_forward.go | 19 +- .../service/openai_gateway_passthrough.go | 13 +- .../openai_gateway_service_hotpath_test.go | 18 + .../openai_image_generation_controls_test.go | 4 + .../service/openai_image_intent_hint.go | 57 +++ .../service/openai_image_intent_hint_test.go | 338 ++++++++++++++++++ 8 files changed, 498 insertions(+), 3 deletions(-) create mode 100644 backend/internal/handler/openai_image_intent_hint_test.go create mode 100644 backend/internal/service/openai_image_intent_hint.go create mode 100644 backend/internal/service/openai_image_intent_hint_test.go diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 231363a8a3..52d039c4ff 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -56,6 +56,13 @@ func openAIModelMappedBody(body []byte, mapped bool, mappedModel string, replace return replace(body, mappedModel) } +func seedOpenAIForwardImageIntentHint(c *gin.Context, channelMapped bool, imageIntent bool) { + if channelMapped { + return + } + service.SetOpenAIImageIntentHint(c, imageIntent) +} + func newOpenAIModelMappedBodyCache(body []byte, replace openAIModelBodyReplaceFunc) func(bool, string) []byte { replacedBodies := make(map[string][]byte) return func(mapped bool, mappedModel string) []byte { @@ -282,6 +289,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody) + seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent) // 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。 if !h.validateFunctionCallOutputRequest(c, body, reqLog) { diff --git a/backend/internal/handler/openai_image_intent_hint_test.go b/backend/internal/handler/openai_image_intent_hint_test.go new file mode 100644 index 0000000000..b9b6c6e2d6 --- /dev/null +++ b/backend/internal/handler/openai_image_intent_hint_test.go @@ -0,0 +1,44 @@ +package handler + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSeedOpenAIForwardImageIntentHint(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + channelMapped bool + imageIntent bool + wantHint bool + }{ + {name: "seed true", imageIntent: true, wantHint: true}, + {name: "seed false", imageIntent: false, wantHint: true}, + {name: "mapped body stays unknown", channelMapped: true, imageIntent: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &gin.Context{} + service.SetOpenAIClientTransport(c, service.OpenAIClientTransportHTTP) + + seedOpenAIForwardImageIntentHint(c, tt.channelMapped, tt.imageIntent) + + var hintValues []bool + for _, value := range c.Keys { + if hint, ok := value.(bool); ok { + hintValues = append(hintValues, hint) + } + } + if !tt.wantHint { + require.Empty(t, hintValues) + return + } + require.Equal(t, []bool{tt.imageIntent}, hintValues) + }) + } +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 6c7d45946a..76a5731504 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -20,6 +20,7 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { startTime := time.Now() + canonicalImageIntentBody := body restrictionResult := s.detectCodexClientRestriction(c, account, body) apiKeyID := getAPIKeyIDFromContext(c) @@ -107,6 +108,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") } if passthroughEnabled { + attemptImageIntentInvalidated := false if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body) if stripErr != nil { @@ -115,6 +117,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if changed { body = strippedBody originalBody = strippedBody + attemptImageIntentInvalidated = true logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy") } } @@ -123,7 +126,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco reasoningEffort := extractOpenAIReasoningEffortFromBody(body, mappedModel) // 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。 reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel) - return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime) + return s.forwardOpenAIPassthrough( + ctx, + c, + account, + originalBody, + canonicalImageIntentBody, + reqModel, + attemptImageIntentInvalidated, + reasoningEffort, + reqStream, + startTime, + ) } bodyModified := false @@ -188,6 +202,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) var imageIntent bool + canonicalImageIntent := resolveOpenAIImageIntentHint(c, reqModel, canonicalImageIntentBody, IsImageGenerationIntent) if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { decoded, decodeErr := ensureReqBody() if decodeErr != nil { @@ -199,7 +214,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded) } else { - imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + imageIntent = canonicalImageIntent } if imageIntent && !imageGenerationAllowed { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 5dc0d180bc..a439061c86 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -29,7 +29,9 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( c *gin.Context, account *Account, body []byte, + canonicalImageIntentBody []byte, reqModel string, + attemptImageIntentInvalidated bool, reasoningEffort *string, reqStream bool, startTime time.Time, @@ -44,6 +46,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } body = nextBody upstreamPassthroughModel = compactMappedModel + attemptImageIntentInvalidated = true } } @@ -100,7 +103,15 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = updatedBody apiKey := getAPIKeyFromContext(c) - imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) + imageIntent := resolveOpenAIPassthroughImageIntent( + c, + reqModel, + canonicalImageIntentBody, + policyModel, + body, + attemptImageIntentInvalidated, + IsImageGenerationIntent, + ) if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusForbidden, gin.H{ diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 326fde534d..f12f47796e 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -221,6 +221,24 @@ func TestOpenAIGatewayService_Forward_MappedImageModelUsesImageGate(t *testing.T require.Nil(t, result) require.Nil(t, upstream.lastReq) require.Equal(t, http.StatusForbidden, rec.Code) + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.False(t, cached) + + textAccount := *account + textAccount.ID = 4 + textAccount.Credentials = map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + } + result, err = svc.Forward(context.Background(), c, &textAccount, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Len(t, upstream.bodies, 1) + cached, known = getOpenAIImageIntentHint(c) + require.True(t, known) + require.False(t, cached) } func TestOpenAIGatewayService_Forward_TextResponsesSetsBillingModelToMappedModel(t *testing.T) { diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go index 090948afd4..77525b1dc6 100644 --- a/backend/internal/service/openai_image_generation_controls_test.go +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -242,6 +242,7 @@ func TestOpenAIGatewayServiceForward_AccountPolicyStripsImageNamespaceTools(t *t } svc := newOpenAIImageGenerationControlTestService(upstream) c, _ := newOpenAIImageGenerationControlTestContext(false, "codex_cli_rs/0.144.1") + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) account := newOpenAIImageGenerationControlTestAccount() account.Extra = map[string]any{ featureKeyCodexImageGenerationExplicitToolPolicy: codexImageGenerationExplicitToolPolicyStrip, @@ -274,6 +275,9 @@ func TestOpenAIGatewayServiceForward_AccountPolicyStripsImageNamespaceTools(t *t require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(name=="shell")`).Exists()) require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(name=="code_tools")`).Exists()) require.Equal(t, "write code", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String()) + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.True(t, cached) }) } } diff --git a/backend/internal/service/openai_image_intent_hint.go b/backend/internal/service/openai_image_intent_hint.go new file mode 100644 index 0000000000..e5a1e8f0b6 --- /dev/null +++ b/backend/internal/service/openai_image_intent_hint.go @@ -0,0 +1,57 @@ +package service + +import "github.com/gin-gonic/gin" + +const openAIImageIntentHintContextKey = "openai_image_intent_hint" + +type openAIImageIntentClassifier func(endpoint string, requestedModel string, body []byte) bool + +// SetOpenAIImageIntentHint records the canonical request body's image intent. +func SetOpenAIImageIntentHint(c *gin.Context, imageIntent bool) { + if c == nil || GetOpenAIClientTransport(c) != OpenAIClientTransportHTTP { + return + } + c.Set(openAIImageIntentHintContextKey, imageIntent) +} + +func getOpenAIImageIntentHint(c *gin.Context) (imageIntent bool, known bool) { + if c == nil || GetOpenAIClientTransport(c) != OpenAIClientTransportHTTP { + return false, false + } + value, ok := c.Get(openAIImageIntentHintContextKey) + if !ok { + return false, false + } + imageIntent, ok = value.(bool) + return imageIntent, ok +} + +func resolveOpenAIImageIntentHint( + c *gin.Context, + requestedModel string, + canonicalBody []byte, + classify openAIImageIntentClassifier, +) bool { + if imageIntent, known := getOpenAIImageIntentHint(c); known { + return imageIntent + } + imageIntent := classify(openAIResponsesEndpoint, requestedModel, canonicalBody) + SetOpenAIImageIntentHint(c, imageIntent) + return imageIntent +} + +func resolveOpenAIPassthroughImageIntent( + c *gin.Context, + canonicalRequestedModel string, + canonicalBody []byte, + attemptRequestedModel string, + attemptBody []byte, + attemptInvalidated bool, + classify openAIImageIntentClassifier, +) bool { + imageIntent := resolveOpenAIImageIntentHint(c, canonicalRequestedModel, canonicalBody, classify) + if attemptInvalidated { + imageIntent = classify(openAIResponsesEndpoint, attemptRequestedModel, attemptBody) + } + return imageIntent +} diff --git a/backend/internal/service/openai_image_intent_hint_test.go b/backend/internal/service/openai_image_intent_hint_test.go new file mode 100644 index 0000000000..be5c61d091 --- /dev/null +++ b/backend/internal/service/openai_image_intent_hint_test.go @@ -0,0 +1,338 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func newOpenAIImageIntentHintTestContext(transport OpenAIClientTransport) *gin.Context { + c := &gin.Context{} + SetOpenAIClientTransport(c, transport) + return c +} + +func countingOpenAIImageIntentClassifier(calls *atomic.Int64) openAIImageIntentClassifier { + return func(endpoint string, requestedModel string, body []byte) bool { + calls.Add(1) + return IsImageGenerationIntent(endpoint, requestedModel, body) + } +} + +func TestResolveOpenAIImageIntentHintCachesTrueAndFalse(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + body []byte + want bool + }{ + {name: "true", body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`), want: true}, + {name: "false is known", body: []byte(`{"model":"gpt-5.4","input":"write code"}`), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + require.Equal(t, tt.want, resolveOpenAIImageIntentHint(c, "gpt-5.4", tt.body, classify)) + require.Equal(t, tt.want, resolveOpenAIImageIntentHint(c, "gpt-5.4", tt.body, classify)) + require.Equal(t, int64(1), calls.Load()) + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.Equal(t, tt.want, cached) + }) + } +} + +func TestResolveOpenAIImageIntentHintUsesHandlerSeed(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, seeded := range []bool{false, true} { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + SetOpenAIImageIntentHint(c, seeded) + var calls atomic.Int64 + + got := resolveOpenAIImageIntentHint(c, "gpt-5.4", []byte(`{"model":"gpt-5.4"}`), countingOpenAIImageIntentClassifier(&calls)) + + require.Equal(t, seeded, got) + require.Zero(t, calls.Load()) + } +} + +func TestResolveOpenAIPassthroughImageIntentReusesCanonicalAcrossFailover(t *testing.T) { + gin.SetMode(gin.TestMode) + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + body := []byte(`{"model":"gpt-5.4","input":"write code"}`) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + for range 3 { + require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", body, "gpt-5.4", body, false, classify)) + } + require.Equal(t, int64(1), calls.Load()) +} + +func TestResolveOpenAIPassthroughImageIntentKeepsCompactMappingAttemptLocal(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Run("text to image", func(t *testing.T) { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + body := []byte(`{"model":"draw-alias","input":"draw"}`) + compactBody := []byte(`{"model":"gpt-image-2","input":"draw"}`) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + require.True(t, resolveOpenAIPassthroughImageIntent(c, "draw-alias", body, "gpt-image-2", compactBody, true, classify)) + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.False(t, cached) + + require.False(t, resolveOpenAIPassthroughImageIntent(c, "draw-alias", body, "draw-alias", body, false, classify)) + require.Equal(t, int64(2), calls.Load()) + cached, known = getOpenAIImageIntentHint(c) + require.True(t, known) + require.False(t, cached) + }) + + t.Run("image to text", func(t *testing.T) { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + body := []byte(`{"model":"gpt-image-2","input":"draw"}`) + compactBody := []byte(`{"model":"gpt-5.4","input":"draw"}`) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", body, "gpt-5.4", compactBody, true, classify)) + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.True(t, cached) + + require.True(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", body, "gpt-image-2", body, false, classify)) + require.Equal(t, int64(2), calls.Load()) + }) +} + +func TestResolveOpenAIPassthroughImageIntentInvalidationDoesNotPolluteCanonical(t *testing.T) { + gin.SetMode(gin.TestMode) + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + canonicalBody := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`) + strippedBody := []byte(`{"model":"gpt-5.4","tools":[]}`) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", canonicalBody, "gpt-5.4", strippedBody, true, classify)) + require.Equal(t, int64(2), calls.Load(), "unknown canonical and invalidated attempt are classified independently") + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.True(t, cached) + + require.True(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", canonicalBody, "gpt-5.4", canonicalBody, false, classify)) + require.Equal(t, int64(2), calls.Load()) +} + +func TestResolveOpenAIPassthroughImageIntentMappedBodyStartsUnknownThenSeedsCanonical(t *testing.T) { + gin.SetMode(gin.TestMode) + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + canonicalBody := []byte(`{"model":"gpt-image-2","input":"draw"}`) + strippedAttemptBody := []byte(`{"model":"gpt-5.4","input":"draw"}`) + _, known := getOpenAIImageIntentHint(c) + require.False(t, known) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", canonicalBody, "gpt-5.4", strippedAttemptBody, true, classify)) + require.Equal(t, int64(2), calls.Load()) + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.True(t, cached) +} + +func TestResolveOpenAIPassthroughImageIntentReusesAcrossInvariantMutations(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + canonicalBody []byte + attemptBody []byte + want bool + }{ + { + name: "oauth sanitize fast policy and reasoning", + canonicalBody: []byte(`{"model":"gpt-5.4","input":[{"type":"input_image","image_url":"data:image/png;base64,"}],"service_tier":"fast","reasoning":{"effort":"minimal"}}`), + attemptBody: []byte(`{"model":"gpt-5.4","input":[],"service_tier":"priority","reasoning":{"effort":"none"},"store":false,"stream":true}`), + want: false, + }, + { + name: "namespace flatten", + canonicalBody: []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"code_tools"}]}`), + attemptBody: []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"code_tools.run"}]}`), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + + require.Equal(t, tt.want, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", tt.canonicalBody, "gpt-5.4", tt.attemptBody, false, classify)) + require.Equal(t, tt.want, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", tt.canonicalBody, "gpt-5.4", tt.attemptBody, false, classify)) + require.Equal(t, int64(1), calls.Load()) + }) + } +} + +func TestOpenAIGatewayServicePassthroughCompactImageIntentIsAttemptLocal(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + canonicalModel string + compactModel string + wantRejected bool + wantCanonical bool + }{ + { + name: "text to image rejects", + canonicalModel: "gpt-5.4", + compactModel: "gpt-image-2", + wantRejected: true, + }, + { + name: "image to text reaches upstream", + canonicalModel: "gpt-image-2", + compactModel: "gpt-5.4", + wantCanonical: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","model":"` + tt.compactModel + `","usage":{"input_tokens":1,"output_tokens":1}}`)), + }} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0") + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + account := newOpenAIImageGenerationControlTestAccount() + account.Extra = map[string]any{"openai_passthrough": true} + account.Credentials = map[string]any{ + "api_key": "sk-test", + "compact_model_mapping": map[string]any{ + tt.canonicalModel: tt.compactModel, + }, + } + body := []byte(`{"model":"` + tt.canonicalModel + `","stream":false,"input":"draw"}`) + + result, err := svc.Forward(context.Background(), c, account, body) + + cached, known := getOpenAIImageIntentHint(c) + require.True(t, known) + require.Equal(t, tt.wantCanonical, cached) + if tt.wantRejected { + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Nil(t, upstream.lastReq) + return + } + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, tt.compactModel, gjson.GetBytes(upstream.lastBody, "model").String()) + }) + } +} + +func TestResolveOpenAIImageIntentHintExcludesWebSocketAndUnknownTransport(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, transport := range []OpenAIClientTransport{OpenAIClientTransportWS, OpenAIClientTransportUnknown} { + c := newOpenAIImageIntentHintTestContext(transport) + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + body := []byte(`{"model":"gpt-5.4","input":"write code"}`) + + require.False(t, resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify)) + require.False(t, resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify)) + require.Equal(t, int64(2), calls.Load()) + _, known := getOpenAIImageIntentHint(c) + require.False(t, known) + } +} + +func TestResolveOpenAIImageIntentHintConcurrentRequestsAreIsolated(t *testing.T) { + gin.SetMode(gin.TestMode) + const requests = 32 + var calls atomic.Int64 + classify := countingOpenAIImageIntentClassifier(&calls) + var wg sync.WaitGroup + results := make([][2]bool, requests) + + for i := range requests { + wg.Add(1) + go func(index int, image bool) { + defer wg.Done() + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + body := []byte(`{"model":"gpt-5.4","input":"write code"}`) + if image { + body = []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`) + } + results[index][0] = resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify) + results[index][1] = resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify) + }(i, i%2 == 0) + } + wg.Wait() + for i, result := range results { + require.Equal(t, i%2 == 0, result[0]) + require.Equal(t, result[0], result[1]) + } + require.Equal(t, int64(requests), calls.Load()) +} + +var openAIImageIntentHintBenchmarkSink bool + +func BenchmarkOpenAIPassthroughImageIntentHintLargeBody(b *testing.B) { + body := []byte(`{"model":"gpt-5.4","input":"` + strings.Repeat("x", 4<<20) + `"}`) + const attempts = 4 + + b.Run("scan_each_attempt", func(b *testing.B) { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + b.ReportAllocs() + calls := 0 + for range b.N { + c.Set(openAIImageIntentHintContextKey, struct{}{}) + for range attempts { + calls++ + openAIImageIntentHintBenchmarkSink = IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body) + } + } + b.ReportMetric(float64(calls)/float64(b.N), "classifier_calls/op") + }) + + b.Run("request_scoped_hint", func(b *testing.B) { + c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP) + b.ReportAllocs() + calls := 0 + classify := func(endpoint string, requestedModel string, candidate []byte) bool { + calls++ + return IsImageGenerationIntent(endpoint, requestedModel, candidate) + } + for range b.N { + c.Set(openAIImageIntentHintContextKey, struct{}{}) + for range attempts { + openAIImageIntentHintBenchmarkSink = resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", body, "gpt-5.4", body, false, classify) + } + } + b.ReportMetric(float64(calls)/float64(b.N), "classifier_calls/op") + }) +} From 4aedbfc4a80526a90c696c9249d3bd9d1b3fe205 Mon Sep 17 00:00:00 2001 From: jjaw Date: Wed, 15 Jul 2026 06:54:59 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E8=A1=A5=E5=85=85=20OpenAI=20=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E5=A4=8D=E7=94=A8=E8=BE=B9=E7=95=8C=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/openai_gateway_handler.go | 1 + backend/internal/service/openai_gateway_forward.go | 1 + backend/internal/service/openai_gateway_passthrough.go | 7 ++++--- backend/internal/service/openai_image_intent_hint.go | 4 +++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 52d039c4ff..5455a3e9f2 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -58,6 +58,7 @@ func openAIModelMappedBody(body []byte, mapped bool, mappedModel string, replace func seedOpenAIForwardImageIntentHint(c *gin.Context, channelMapped bool, imageIntent bool) { if channelMapped { + // 渠道映射改变了规范请求,保持 unknown,由 Forward 按映射后的 model/body 初始化。 return } service.SetOpenAIImageIntentHint(c, imageIntent) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 76a5731504..e4915f3eff 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -20,6 +20,7 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { startTime := time.Now() + // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 canonicalImageIntentBody := body restrictionResult := s.detectCodexClientRestriction(c, account, body) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index a439061c86..d27fa07985 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -1,8 +1,6 @@ package service -// 本文件由 openai_gateway_service.go 纯移动拆分而来:/v1/responses 直通 -// (passthrough)转发路径及其流式/非流式响应处理与错误处理。仅做代码搬迁, -// 无任何行为变更。 +// 本文件承载 /v1/responses 透传转发及其流式、非流式响应与错误处理。 import ( "bufio" @@ -103,6 +101,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( body = updatedBody apiKey := getAPIKeyFromContext(c) + // 同一 attempt 的最终 model/body 只判定一次,权限检查与后续图片状态设置共用该结果。 imageIntent := resolveOpenAIPassthroughImageIntent( c, reqModel, @@ -874,7 +873,9 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( failedMessage := "" clientOutputStarted := false upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id")) + // pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。 pendingLines := make([]string, 0, 8) + // flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。 flushPending := false flushPendingOutput := func() { if clientDisconnected || !flushPending { diff --git a/backend/internal/service/openai_image_intent_hint.go b/backend/internal/service/openai_image_intent_hint.go index e5a1e8f0b6..f8f966e5fc 100644 --- a/backend/internal/service/openai_image_intent_hint.go +++ b/backend/internal/service/openai_image_intent_hint.go @@ -2,11 +2,12 @@ package service import "github.com/gin-gonic/gin" +// 请求级 hint 仅限 HTTP:缺失表示 unknown,false/true 都表示已完成 canonical 判定。 const openAIImageIntentHintContextKey = "openai_image_intent_hint" type openAIImageIntentClassifier func(endpoint string, requestedModel string, body []byte) bool -// SetOpenAIImageIntentHint records the canonical request body's image intent. +// SetOpenAIImageIntentHint 只写入请求级 canonical 判定,不记录 attempt-local 结果。 func SetOpenAIImageIntentHint(c *gin.Context, imageIntent bool) { if c == nil || GetOpenAIClientTransport(c) != OpenAIClientTransportHTTP { return @@ -51,6 +52,7 @@ func resolveOpenAIPassthroughImageIntent( ) bool { imageIntent := resolveOpenAIImageIntentHint(c, canonicalRequestedModel, canonicalBody, classify) if attemptInvalidated { + // strip/compact 改写只重算当前 attempt,不得把变换后的结果写回请求级 canonical hint。 imageIntent = classify(openAIResponsesEndpoint, attemptRequestedModel, attemptBody) } return imageIntent