diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 07e8c57bc1..fb05b64b01 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -292,6 +292,11 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( return nil, err } + body, contentType, err = prepareGrokMediaForwardBody(endpoint, body, contentType) + if err != nil { + return nil, err + } + var bodyReader io.Reader if endpoint.RequiresRequestBody() { bodyReader = bytes.NewReader(body) @@ -356,6 +361,69 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( }, nil } +func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { + if endpoint != GrokMediaEndpointImagesEdits || gjson.ValidBytes(body) { + return body, contentType, nil + } + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType)) + if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { + return body, contentType, nil + } + + info := ParseGrokMediaRequest(contentType, body) + payload := make(map[string]any) + if info.Model != "" { + payload["model"] = info.Model + } + if info.Prompt != "" { + payload["prompt"] = info.Prompt + } + if info.N > 1 { + payload["n"] = info.N + } + if info.Size != "" { + payload["size"] = info.Size + } + + images := make([]map[string]string, 0, len(info.InputImageURLs)+len(info.Uploads)) + for _, imageURL := range info.InputImageURLs { + if imageURL = strings.TrimSpace(imageURL); imageURL != "" { + images = append(images, map[string]string{"image_url": imageURL}) + } + } + for _, upload := range info.Uploads { + dataURL, err := openAIImageUploadToDataURL(upload) + if err != nil { + return nil, "", err + } + images = append(images, map[string]string{"image_url": dataURL}) + } + if len(images) > 0 { + payload["image"] = images[0] + if len(images) > 1 { + payload["images"] = images + } + } + + maskImageURL := strings.TrimSpace(info.MaskImageURL) + if info.MaskUpload != nil { + dataURL, err := openAIImageUploadToDataURL(*info.MaskUpload) + if err != nil { + return nil, "", err + } + maskImageURL = dataURL + } + if maskImageURL != "" { + payload["mask"] = map[string]string{"image_url": maskImageURL} + } + + out, err := marshalOpenAIUpstreamJSON(payload) + if err != nil { + return nil, "", err + } + return out, "application/json", nil +} + type grokMediaUsageMetadata struct { ResponseID string Usage OpenAIUsage diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index a095243f57..ad088be725 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -229,6 +229,58 @@ func TestForwardGrokMediaImagesGenerationPassthrough(t *testing.T) { require.Equal(t, ImageBillingSize2K, result.ImageSize) } +func TestForwardGrokMediaImagesEditMultipartConvertsToJSON(t *testing.T) { + t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + require.NoError(t, writer.WriteField("model", "grok-imagine-edit")) + require.NoError(t, writer.WriteField("prompt", "edit this private image")) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", `form-data; name="image"; filename="input.png"`) + partHeader.Set("Content-Type", "image/png") + part, err := writer.CreatePart(partHeader) + require.NoError(t, err) + _, err = part.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(buf.Bytes())) + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + + account := &Account{ + ID: 62, + Name: "grok", + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "api-key", + "base_url": "https://xai.test/v1", + }, + } + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream} + + _, err = svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointImagesEdits, "", buf.Bytes(), writer.FormDataContentType()) + require.NoError(t, err) + require.Equal(t, "https://xai.test/v1/images/edits", upstream.lastReq.URL.String()) + require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type")) + require.True(t, json.Valid(upstream.lastBody)) + require.Equal(t, "grok-imagine-edit", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "edit this private image", gjson.GetBytes(upstream.lastBody, "prompt").String()) + require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "image.image_url").String(), "data:image/png;base64,")) +} + func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) { t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true") gin.SetMode(gin.TestMode)