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