diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ffab74d6a7a..90f8fc16095 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -698,9 +698,10 @@ func (h *AccountHandler) Delete(c *gin.Context) { // TestAccountRequest represents the request body for testing an account type TestAccountRequest struct { - ModelID string `json:"model_id"` - Prompt string `json:"prompt"` - Mode string `json:"mode"` + ModelID string `json:"model_id"` + Prompt string `json:"prompt"` + Mode string `json:"mode"` + PromptOptimization *bool `json:"prompt_optimization"` } type SyncFromCRSRequest struct { @@ -731,7 +732,7 @@ func (h *AccountHandler) Test(c *gin.Context) { _ = c.ShouldBindJSON(&req) // Use AccountTestService to test the account with SSE streaming - if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil { + if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, req.PromptOptimization); err != nil { // Error already sent via SSE, just log return } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index b9cd698a7b5..f1c5af0967d 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -170,7 +170,7 @@ func createTestPayload(modelID string) (map[string]any, error) { // All account types use full Claude Code client characteristics, only auth header differs // modelID is optional - if empty, defaults to claude.DefaultTestModel // mode is optional - "compact" routes OpenAI accounts to the /responses/compact probe path -func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string, mode string) error { +func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string, mode string, promptOptimization *bool) error { ctx := c.Request.Context() // Get account @@ -181,7 +181,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int // Route to platform-specific test method if account.IsOpenAI() { - return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode)) + return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode), promptOptimization) } if account.IsGemini() { @@ -492,9 +492,8 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co } // testOpenAIAccountConnection tests an OpenAI account's connection -func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account *Account, modelID string, prompt string, mode string) error { +func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account *Account, modelID string, prompt string, mode string, promptOptimization *bool) error { ctx := c.Request.Context() - _ = prompt mode = normalizeAccountTestMode(mode) // Default to openai.DefaultTestModel for OpenAI testing @@ -520,7 +519,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account if account.Type == "apikey" { return s.testOpenAIImageAPIKey(c, ctx, account, testModelID, imagePrompt) } - return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt) + return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt, promptOptimization) } // Determine authentication method and API URL @@ -1415,7 +1414,7 @@ func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.C } // testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via Codex /responses API. -func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error { +func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string, promptOptimization *bool) error { authToken := account.GetOpenAIAccessToken() if authToken == "" { return s.sendErrorAndEnd(c, "No access token available") @@ -1432,9 +1431,10 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co s.sendEvent(c, TestEvent{Type: "content", Text: "Calling Codex /responses image tool...\n"}) parsed := &OpenAIImagesRequest{ - Endpoint: openAIImagesGenerationsEndpoint, - Model: strings.TrimSpace(modelID), - Prompt: prompt, + Endpoint: openAIImagesGenerationsEndpoint, + Model: strings.TrimSpace(modelID), + Prompt: prompt, + PromptOptimization: promptOptimization, } applyOpenAIImagesDefaults(parsed) @@ -1538,7 +1538,7 @@ func (s *AccountTestService) RunTestBackground(ctx context.Context, accountID in ginCtx, _ := gin.CreateTestContext(w) ginCtx.Request = (&http.Request{}).WithContext(ctx) - testErr := s.TestAccountConnection(ginCtx, accountID, modelID, "", AccountTestModeDefault) + testErr := s.TestAccountConnection(ginCtx, accountID, modelID, "", AccountTestModeDefault, nil) finishedAt := time.Now() body := w.Body.String() diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go index 9eb98fdc8b0..84c0b1f2452 100644 --- a/backend/internal/service/account_test_service_openai_compact_test.go +++ b/backend/internal/service/account_test_service_openai_compact_test.go @@ -49,7 +49,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", bytes.NewReader(nil)) - err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) + err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact, nil) require.NoError(t, err) require.Equal(t, chatgptCodexAPIURL+"/compact", upstream.lastReq.URL.String()) @@ -102,7 +102,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuth404MarksUnsu c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/2/test", bytes.NewReader(nil)) - err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) + err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact, nil) require.Error(t, err) updates := <-updateCalls @@ -148,7 +148,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/3/test", bytes.NewReader(nil)) - err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) + err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact, nil) require.NoError(t, err) require.Equal(t, "https://example.com/v1/responses/compact", upstream.lastReq.URL.String()) @@ -192,7 +192,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBase c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/4/test", bytes.NewReader(nil)) - err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact) + err := svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact, nil) require.NoError(t, err) require.Equal(t, "https://api.openai.com/v1/responses/compact", upstream.lastReq.URL.String()) <-updateCalls diff --git a/backend/internal/service/account_test_service_openai_image_test.go b/backend/internal/service/account_test_service_openai_image_test.go index 257159c40f6..a801925a0fa 100644 --- a/backend/internal/service/account_test_service_openai_image_test.go +++ b/backend/internal/service/account_test_service_openai_image_test.go @@ -43,13 +43,50 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes }, } - err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2", "draw a cat") + err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2", "draw a cat", nil) require.NoError(t, err) require.Contains(t, rec.Body.String(), "Calling Codex /responses image tool") require.Contains(t, rec.Body.String(), "data:image/png;base64,aGVsbG8=") require.Contains(t, rec.Body.String(), "\"success\":true") } +func TestAccountTestService_OpenAIImageOAuthCanDisablePromptOptimization(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_123\",\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"output_format\":\"png\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000006,\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[]}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + svc := &AccountTestService{httpUpstream: upstream} + account := &Account{ + ID: 53, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + disabled := false + err := svc.testOpenAIImageOAuth(c, context.Background(), account, "gpt-image-2", "draw a cat", &disabled) + require.NoError(t, err) + require.Contains(t, string(upstream.lastBody), "Do not rewrite") + require.Contains(t, rec.Body.String(), "\"success\":true") +} + func TestAccountTestService_OpenAIImageAPIKeyUsesConfiguredV1BaseURL(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index c6e30ed63c6..1f95eea27bf 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -125,7 +125,7 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing. Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.NoError(t, err) require.NotEmpty(t, repo.updatedExtra) require.Equal(t, 42.0, repo.updatedExtra["codex_5h_used_percent"]) @@ -152,7 +152,7 @@ func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) { Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.Contains(t, recorder.Body.String(), "response.completed") require.NotContains(t, recorder.Body.String(), `"success":true`) @@ -182,7 +182,7 @@ func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testin Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.NotEmpty(t, repo.updatedExtra) require.Equal(t, 100.0, repo.updatedExtra["codex_5h_used_percent"]) @@ -213,7 +213,7 @@ func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleErro Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.Equal(t, account.ID, repo.rateLimitedID) require.NotNil(t, repo.rateLimitedAt) @@ -242,7 +242,7 @@ func TestAccountTestService_OpenAI429SyncsObservedPlanType(t *testing.T) { Credentials: map[string]any{"access_token": "test-token", "plan_type": "plus"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.Equal(t, []int64{account.ID}, repo.bulkUpdatedIDs) require.Equal(t, "free", repo.bulkUpdatedPayload.Credentials["plan_type"]) @@ -269,7 +269,7 @@ func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.Equal(t, account.ID, repo.rateLimitedID) require.NotNil(t, repo.rateLimitedAt) @@ -297,7 +297,7 @@ func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.Zero(t, repo.rateLimitedID) require.Nil(t, repo.rateLimitedAt) @@ -325,7 +325,7 @@ func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) { Credentials: map[string]any{"access_token": "test-token"}, } - err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "") + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "", nil) require.Error(t, err) require.Equal(t, account.ID, repo.setErrorID) require.Contains(t, repo.setErrorMsg, "Authentication failed (401)") diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index afa94156867..c5e76fb9e9c 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -65,6 +65,7 @@ type OpenAIImagesRequest struct { Model string ExplicitModel bool Prompt string + PromptOptimization *bool Stream bool N int Size string @@ -229,6 +230,11 @@ func parseOpenAIImagesJSONRequest(body []byte, req *OpenAIImagesRequest) error { req.ExplicitModel = req.Model != "" } req.Prompt = strings.TrimSpace(gjson.GetBytes(body, "prompt").String()) + promptOptimization, err := parseOpenAIImagesJSONOptionalBool(body, "prompt_optimization", "promptOptimization") + if err != nil { + return err + } + req.PromptOptimization = promptOptimization if streamResult := gjson.GetBytes(body, "stream"); streamResult.Exists() { if streamResult.Type != gjson.True && streamResult.Type != gjson.False { @@ -374,6 +380,12 @@ func parseOpenAIImagesMultipartRequest(body []byte, contentType string, req *Ope req.ExplicitModel = value != "" case "prompt": req.Prompt = value + case "prompt_optimization", "promptOptimization": + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid %s field value", name) + } + req.PromptOptimization = &parsed case "size": req.Size = value req.ExplicitSize = value != "" @@ -436,6 +448,29 @@ func parseOpenAIImagesMultipartRequest(body []byte, contentType string, req *Ope return nil } +func parseOpenAIImagesJSONOptionalBool(body []byte, paths ...string) (*bool, error) { + for _, path := range paths { + result := gjson.GetBytes(body, path) + if !result.Exists() { + continue + } + switch result.Type { + case gjson.True, gjson.False: + value := result.Bool() + return &value, nil + case gjson.String: + value, err := strconv.ParseBool(strings.TrimSpace(result.String())) + if err != nil { + return nil, fmt.Errorf("invalid %s field value", path) + } + return &value, nil + default: + return nil, fmt.Errorf("invalid %s field type", path) + } + } + return nil, nil +} + func parseOpenAIImageDimensions(_ textproto.MIMEHeader) (int, int) { return 0, 0 } @@ -808,21 +843,46 @@ func buildOpenAIImagesURL(base string, endpoint string) string { func rewriteOpenAIImagesModel(body []byte, contentType string, model string) ([]byte, string, error) { model = strings.TrimSpace(model) - if model == "" { - return body, contentType, nil - } mediaType, _, err := mime.ParseMediaType(contentType) if err == nil && strings.EqualFold(mediaType, "multipart/form-data") { rewrittenBody, rewrittenType, rewriteErr := rewriteOpenAIImagesMultipartModel(body, contentType, model) return rewrittenBody, rewrittenType, rewriteErr } - rewritten, err := sjson.SetBytes(body, "model", model) + rewritten, err := stripOpenAIImagesInternalJSONFields(body) + if err != nil { + return nil, "", err + } + if model == "" { + return rewritten, contentType, nil + } + rewritten, err = sjson.SetBytes(rewritten, "model", model) if err != nil { return nil, "", fmt.Errorf("rewrite image request model: %w", err) } return rewritten, contentType, nil } +func stripOpenAIImagesInternalJSONFields(body []byte) ([]byte, error) { + rewritten := body + var err error + for _, path := range []string{"prompt_optimization", "promptOptimization"} { + rewritten, err = sjson.DeleteBytes(rewritten, path) + if err != nil { + return nil, fmt.Errorf("strip image request field %s: %w", path, err) + } + } + return rewritten, nil +} + +func isOpenAIImagesInternalMultipartField(name string) bool { + switch strings.TrimSpace(name) { + case "prompt_optimization", "promptOptimization": + return true + default: + return false + } +} + func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model string) ([]byte, string, error) { _, params, err := mime.ParseMediaType(contentType) if err != nil { @@ -848,6 +908,10 @@ func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model st } formName := strings.TrimSpace(part.FormName()) + if part.FileName() == "" && isOpenAIImagesInternalMultipartField(formName) { + _ = part.Close() + continue + } partHeader := cloneMultipartHeader(part.Header) target, err := writer.CreatePart(partHeader) if err != nil { @@ -855,7 +919,7 @@ func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model st return nil, "", fmt.Errorf("create multipart part: %w", err) } - if formName == "model" && part.FileName() == "" { + if formName == "model" && part.FileName() == "" && model != "" { if _, err := target.Write([]byte(model)); err != nil { _ = part.Close() return nil, "", fmt.Errorf("rewrite multipart model: %w", err) @@ -871,7 +935,7 @@ func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model st _ = part.Close() } - if !modelWritten { + if !modelWritten && model != "" { if err := writer.WriteField("model", model); err != nil { return nil, "", fmt.Errorf("append multipart model field: %w", err) } diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 25cd8228a83..4de45b82d61 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -227,7 +227,13 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st return nil, fmt.Errorf("image input is required") } + instructions := "" + if parsed.PromptOptimization != nil && !*parsed.PromptOptimization { + instructions = "Use the user's original image prompt exactly as written for image generation. Do not rewrite, expand, translate, polish, or optimize the prompt before calling the image_generation tool." + } + req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`) + req, _ = sjson.SetBytes(req, "instructions", instructions) req, _ = sjson.SetBytes(req, "model", openAIImagesResponsesMainModel) input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`) diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 45fb24e975e..2fc273a7a81 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "mime" "mime/multipart" "net/http" "net/http/httptest" @@ -34,7 +35,7 @@ func (w *failingOpenAIImageWriter) Write(p []byte) (int, error) { func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) { gin.SetMode(gin.TestMode) - body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","stream":true}`) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","prompt_optimization":false,"size":"1024x1024","quality":"high","stream":true}`) req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") @@ -49,6 +50,8 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) { require.Equal(t, "/v1/images/generations", parsed.Endpoint) require.Equal(t, "gpt-image-2", parsed.Model) require.Equal(t, "draw a cat", parsed.Prompt) + require.NotNil(t, parsed.PromptOptimization) + require.False(t, *parsed.PromptOptimization) require.True(t, parsed.Stream) require.Equal(t, "1024x1024", parsed.Size) require.Equal(t, "1K", parsed.SizeTier) @@ -63,6 +66,7 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T writer := multipart.NewWriter(&body) require.NoError(t, writer.WriteField("model", "gpt-image-2")) require.NoError(t, writer.WriteField("prompt", "replace background")) + require.NoError(t, writer.WriteField("promptOptimization", "false")) require.NoError(t, writer.WriteField("size", "1536x1024")) part, err := writer.CreateFormFile("image", "source.png") require.NoError(t, err) @@ -84,12 +88,67 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T require.True(t, parsed.Multipart) require.Equal(t, "gpt-image-2", parsed.Model) require.Equal(t, "replace background", parsed.Prompt) + require.NotNil(t, parsed.PromptOptimization) + require.False(t, *parsed.PromptOptimization) require.Equal(t, "1536x1024", parsed.Size) require.Equal(t, "2K", parsed.SizeTier) require.Len(t, parsed.Uploads, 1) require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability) } +func TestRewriteOpenAIImagesModel_StripsInternalJSONFields(t *testing.T) { + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","prompt_optimization":false,"promptOptimization":true}`) + + rewritten, contentType, err := rewriteOpenAIImagesModel(body, "application/json", "gpt-image-1") + + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + require.Equal(t, "gpt-image-1", gjson.GetBytes(rewritten, "model").String()) + require.False(t, gjson.GetBytes(rewritten, "prompt_optimization").Exists()) + require.False(t, gjson.GetBytes(rewritten, "promptOptimization").Exists()) +} + +func TestRewriteOpenAIImagesModel_StripsInternalMultipartFields(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("model", "gpt-image-2")) + require.NoError(t, writer.WriteField("prompt", "replace background")) + require.NoError(t, writer.WriteField("prompt_optimization", "false")) + part, err := writer.CreateFormFile("image", "source.png") + require.NoError(t, err) + _, err = part.Write([]byte("fake-image-bytes")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + rewritten, contentType, err := rewriteOpenAIImagesModel(body.Bytes(), writer.FormDataContentType(), "gpt-image-1") + + require.NoError(t, err) + _, params, err := mime.ParseMediaType(contentType) + require.NoError(t, err) + reader := multipart.NewReader(bytes.NewReader(rewritten), params["boundary"]) + fields := map[string]string{} + files := 0 + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + data, err := io.ReadAll(part) + require.NoError(t, err) + if part.FileName() != "" { + files++ + } else { + fields[part.FormName()] = string(data) + } + require.NoError(t, part.Close()) + } + require.Equal(t, "gpt-image-1", fields["model"]) + require.Equal(t, "replace background", fields["prompt"]) + require.NotContains(t, fields, "prompt_optimization") + require.Equal(t, 1, files) +} + func TestOpenAIImagesRequestModerationBody_JSONEditIncludesInputImageURLs(t *testing.T) { parsed := &OpenAIImagesRequest{ Endpoint: openAIImagesEditsEndpoint, @@ -1124,6 +1183,24 @@ func TestBuildOpenAIImagesResponsesRequest_DowngradesMultipleImagesToSingle(t *t require.Equal(t, "draw a cat", gjson.GetBytes(body, "input.0.content.0.text").String()) } +func TestBuildOpenAIImagesResponsesRequest_DisablesPromptOptimization(t *testing.T) { + promptOptimization := false + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesGenerationsEndpoint, + Model: "gpt-image-2", + Prompt: "draw a cat exactly", + PromptOptimization: &promptOptimization, + } + + body, err := buildOpenAIImagesResponsesRequest(parsed, "gpt-image-2") + require.NoError(t, err) + require.NotNil(t, body) + require.Equal(t, "draw a cat exactly", gjson.GetBytes(body, "input.0.content.0.text").String()) + instructions := gjson.GetBytes(body, "instructions").String() + require.Contains(t, instructions, "original image prompt") + require.Contains(t, instructions, "Do not rewrite") +} + func TestBuildOpenAIImagesResponsesRequest_StripsInputFidelity(t *testing.T) { parsed := &OpenAIImagesRequest{ Endpoint: openAIImagesEditsEndpoint, diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 2e3db61bfdc..b39cf42f997 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -66,6 +66,21 @@ /> +