diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go index 80b6c66d56..12590f3f63 100644 --- a/backend/internal/service/image_generation_intent.go +++ b/backend/internal/service/image_generation_intent.go @@ -39,6 +39,9 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte if openAIJSONToolsContainImageGeneration(gjson.GetBytes(body, "tools")) { return true } + if openAIJSONInputContainsImageGenTool(gjson.GetBytes(body, "input")) { + return true + } return openAIJSONToolChoiceSelectsImageGeneration(gjson.GetBytes(body, "tool_choice")) } @@ -94,11 +97,52 @@ func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool { found = true return false } + if isImageGenNamespaceTool(item) { + found = true + return false + } return true }) return found } +// isImageGenNamespaceTool detects the Codex namespace-style image generation +// tool declaration: { "type": "namespace", "name": "image_gen", ... }. +// Codex /image uses this instead of the flat { "type": "image_generation" }. +func isImageGenNamespaceTool(tool gjson.Result) bool { + return openAIJSONString(tool.Get("type")) == "namespace" && + openAIJSONString(tool.Get("name")) == "image_gen" +} + +// openAIJSONInputContainsImageGenTool scans Responses input items for +// additional_tools entries that declare the image_gen namespace. This covers +// the "Responses Lite" format where tools are embedded inside input items +// rather than top-level tools. +func openAIJSONInputContainsImageGenTool(input gjson.Result) bool { + if !input.IsArray() { + return false + } + found := false + input.ForEach(func(_, item gjson.Result) bool { + if openAIJSONString(item.Get("type")) != "additional_tools" { + return true + } + tools := item.Get("tools") + if !tools.IsArray() { + return true + } + tools.ForEach(func(_, tool gjson.Result) bool { + if isImageGenNamespaceTool(tool) { + found = true + return false + } + return true + }) + return !found + }) + return found +} + func openAIRequestBodyHasImageGenerationTool(body []byte) bool { if len(body) == 0 || !gjson.ValidBytes(body) { return false diff --git a/backend/internal/service/image_generation_intent_test.go b/backend/internal/service/image_generation_intent_test.go index 59aab39c4b..1a32318cce 100644 --- a/backend/internal/service/image_generation_intent_test.go +++ b/backend/internal/service/image_generation_intent_test.go @@ -55,6 +55,27 @@ func TestIsImageGenerationIntent(t *testing.T) { body: []byte(`{"model":"gpt-5.4","input":"write code"}`), want: false, }, + { + name: "namespace image_gen tool in top-level tools", + endpoint: "/v1/responses", + model: "gpt-5.5", + body: []byte(`{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]}`), + want: true, + }, + { + name: "namespace image_gen in input additional_tools (Responses Lite)", + endpoint: "/v1/responses", + model: "gpt-5.5", + body: []byte(`{"model":"gpt-5.5","input":[{"type":"additional_tools","role":"developer","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]}]}`), + want: true, + }, + { + name: "non-image namespace tool is not flagged", + endpoint: "/v1/responses", + model: "gpt-5.5", + body: []byte(`{"model":"gpt-5.5","tools":[{"type":"namespace","name":"code_tools","tools":[{"type":"function","name":"run"}]}]}`), + want: false, + }, } for _, tt := range tests { @@ -64,6 +85,58 @@ func TestIsImageGenerationIntent(t *testing.T) { } } +func TestIsImageGenerationIntentMap_NamespaceImageGen(t *testing.T) { + tests := []struct { + name string + reqBody map[string]any + want bool + }{ + { + name: "top-level namespace image_gen", + reqBody: map[string]any{ + "model": "gpt-5.5", + "tools": []any{ + map[string]any{"type": "namespace", "name": "image_gen", "tools": []any{ + map[string]any{"type": "function", "name": "imagegen"}, + }}, + }, + }, + want: true, + }, + { + name: "additional_tools in input", + reqBody: map[string]any{ + "model": "gpt-5.5", + "input": []any{ + map[string]any{ + "type": "additional_tools", + "tools": []any{ + map[string]any{"type": "namespace", "name": "image_gen"}, + }, + }, + }, + }, + want: true, + }, + { + name: "non-image namespace not flagged", + reqBody: map[string]any{ + "model": "gpt-5.5", + "tools": []any{ + map[string]any{"type": "namespace", "name": "code_tools"}, + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsImageGenerationIntentMap("/v1/responses", "gpt-5.5", tt.reqBody)) + }) + } +} + func TestResolveOpenAIResponsesImageBillingConfigUsesCurrentBodyModel(t *testing.T) { imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody( []byte(`{"model":"mapped-image-model","tools":[{"type":"image_generation","size":"1024x1024"}]}`), diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 44c420edbe..36426e7377 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -593,8 +593,14 @@ func isCodexSparkModel(model string) bool { } func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { - rawTools, ok := reqBody["tools"] - if !ok || rawTools == nil { + if toolsContainImageGeneration(reqBody["tools"]) { + return true + } + return inputContainsImageGenNamespace(reqBody["input"]) +} + +func toolsContainImageGeneration(rawTools any) bool { + if rawTools == nil { return false } tools, ok := rawTools.([]any) @@ -609,6 +615,34 @@ func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { if strings.TrimSpace(firstNonEmptyString(toolMap["type"])) == "image_generation" { return true } + if isImageGenNamespaceToolMap(toolMap) { + return true + } + } + return false +} + +func isImageGenNamespaceToolMap(tool map[string]any) bool { + return strings.TrimSpace(firstNonEmptyString(tool["type"])) == "namespace" && + strings.TrimSpace(firstNonEmptyString(tool["name"])) == "image_gen" +} + +func inputContainsImageGenNamespace(rawInput any) bool { + input, ok := rawInput.([]any) + if !ok { + return false + } + for _, rawItem := range input { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + if strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { + continue + } + if toolsContainImageGeneration(item["tools"]) { + return true + } } return false }