diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 457877cb8d..2d0630230a 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -147,7 +147,7 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { switch { case value.IsArray(): for _, item := range value.Array() { - if imageURL := strings.TrimSpace(item.Get("image_url").String()); imageURL != "" { + if imageURL := grokMediaJSONImageURL(item); imageURL != "" { info.InputImageURLs = append(info.InputImageURLs, imageURL) continue } @@ -160,7 +160,7 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { } } default: - if imageURL := strings.TrimSpace(value.Get("image_url").String()); imageURL != "" { + if imageURL := grokMediaJSONImageURL(value); imageURL != "" { info.InputImageURLs = append(info.InputImageURLs, imageURL) return } @@ -175,7 +175,15 @@ func parseGrokMediaJSONRequest(body []byte, info *GrokMediaRequestInfo) { } appendJSONImageURLs(gjson.GetBytes(body, "image")) appendJSONImageURLs(gjson.GetBytes(body, "images")) - info.MaskImageURL = strings.TrimSpace(gjson.GetBytes(body, "mask.image_url").String()) + appendJSONImageURLs(gjson.GetBytes(body, "reference_images")) + info.MaskImageURL = grokMediaJSONImageURL(gjson.GetBytes(body, "mask")) +} + +func grokMediaJSONImageURL(value gjson.Result) string { + if imageURL := strings.TrimSpace(value.Get("url").String()); imageURL != "" { + return imageURL + } + return strings.TrimSpace(value.Get("image_url").String()) } func parseGrokMediaMultipartRequest(contentType string, body []byte, info *GrokMediaRequestInfo) { @@ -409,7 +417,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten 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}) + images = append(images, map[string]string{"url": imageURL}) } } for _, upload := range info.Uploads { @@ -417,7 +425,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten if err != nil { return nil, "", err } - images = append(images, map[string]string{"image_url": dataURL}) + images = append(images, map[string]string{"url": dataURL}) } if len(images) > 0 { payload["image"] = images[0] @@ -435,7 +443,7 @@ func prepareGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, conten maskImageURL = dataURL } if maskImageURL != "" { - payload["mask"] = map[string]string{"image_url": maskImageURL} + payload["mask"] = map[string]string{"url": maskImageURL} } out, err := marshalOpenAIUpstreamJSON(payload) @@ -449,6 +457,18 @@ func normalizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, cont if !endpoint.RequiresRequestBody() || !gjson.ValidBytes(body) { return body, contentType, nil } + var imageFields []string + switch endpoint { + case GrokMediaEndpointImagesEdits: + imageFields = []string{"image", "images", "mask"} + case GrokMediaEndpointVideosGenerations: + imageFields = []string{"image", "images", "reference_images"} + } + var err error + body, err = canonicalizeGrokMediaImageURLFields(body, imageFields...) + if err != nil { + return nil, "", err + } info := ParseGrokMediaRequest(contentType, body) upstreamModel := normalizeGrokMediaModelForEndpoint(endpoint, info.Model, info.HasInputImage()) if upstreamModel == "" || upstreamModel == info.Model { @@ -461,6 +481,54 @@ func normalizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, cont return out, contentType, nil } +func canonicalizeGrokMediaImageURLFields(body []byte, fields ...string) ([]byte, error) { + out := body + for _, field := range fields { + value := gjson.GetBytes(out, field) + if !value.Exists() { + continue + } + if value.IsArray() { + for index := range value.Array() { + var err error + out, err = canonicalizeGrokMediaImageURLObject(out, fmt.Sprintf("%s.%d", field, index)) + if err != nil { + return nil, err + } + } + continue + } + var err error + out, err = canonicalizeGrokMediaImageURLObject(out, field) + if err != nil { + return nil, err + } + } + return out, nil +} + +func canonicalizeGrokMediaImageURLObject(body []byte, path string) ([]byte, error) { + legacyPath := path + ".image_url" + legacy := gjson.GetBytes(body, legacyPath) + if !legacy.Exists() { + return body, nil + } + + out := body + if !gjson.GetBytes(out, path+".url").Exists() { + var err error + out, err = sjson.SetBytes(out, path+".url", legacy.Value()) + if err != nil { + return nil, fmt.Errorf("normalize grok media image url: %w", err) + } + } + out, err := sjson.DeleteBytes(out, legacyPath) + if err != nil { + return nil, fmt.Errorf("remove legacy grok media image url: %w", err) + } + return out, nil +} + func sanitizeGrokMediaForwardBody(endpoint GrokMediaEndpoint, body []byte, contentType string) ([]byte, string, error) { if !endpoint.RequiresRequestBody() || !gjson.ValidBytes(body) { return body, contentType, nil diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go index 71e04d867e..df574d46a1 100644 --- a/backend/internal/service/openai_gateway_grok_test.go +++ b/backend/internal/service/openai_gateway_grok_test.go @@ -418,6 +418,72 @@ func TestParseGrokMediaVideoRequestResolution(t *testing.T) { require.Equal(t, "720p", info.Resolution) } +func TestParseGrokMediaRequestAcceptsOfficialImageURLFields(t *testing.T) { + body := []byte(`{ + "model":"grok-imagine-video-1.5", + "image":{"url":"https://example.com/source.png"}, + "reference_images":[{"url":"https://example.com/reference.png"}] + }`) + + info := ParseGrokMediaRequest("application/json", body) + + require.Equal(t, []string{ + "https://example.com/source.png", + "https://example.com/reference.png", + }, info.InputImageURLs) + require.True(t, info.HasInputImage()) +} + +func TestNormalizeGrokMediaForwardBodyCanonicalizesImageURLAlias(t *testing.T) { + body := []byte(`{ + "model":"grok-imagine-video-1.5", + "prompt":"animate", + "image":{"image_url":"https://example.com/source.png"}, + "duration":8 + }`) + + out, contentType, err := normalizeGrokMediaForwardBody(GrokMediaEndpointVideosGenerations, body, "application/json") + + require.NoError(t, err) + require.Equal(t, "application/json", contentType) + require.Equal(t, "grok-imagine-video-1.5", gjson.GetBytes(out, "model").String()) + require.Equal(t, "https://example.com/source.png", gjson.GetBytes(out, "image.url").String()) + require.False(t, gjson.GetBytes(out, "image.image_url").Exists()) +} + +func TestNormalizeGrokMediaForwardBodyPreservesImageToVideoModelForOfficialURL(t *testing.T) { + body := []byte(`{ + "model":"grok-imagine-video-1.5", + "prompt":"animate", + "image":{"url":"https://example.com/source.png"} + }`) + + out, _, err := normalizeGrokMediaForwardBody(GrokMediaEndpointVideosGenerations, body, "application/json") + + require.NoError(t, err) + require.Equal(t, "grok-imagine-video-1.5", gjson.GetBytes(out, "model").String()) + require.Equal(t, "https://example.com/source.png", gjson.GetBytes(out, "image.url").String()) +} + +func TestCanonicalizeGrokMediaImageURLFieldsPreservesOfficialURL(t *testing.T) { + body := []byte(`{ + "image":{"url":"https://example.com/official.png","image_url":"https://example.com/legacy.png"}, + "images":[ + {"image_url":"https://example.com/first.png"}, + {"url":"https://example.com/second.png"} + ] + }`) + + out, err := canonicalizeGrokMediaImageURLFields(body, "image", "images") + + require.NoError(t, err) + require.Equal(t, "https://example.com/official.png", gjson.GetBytes(out, "image.url").String()) + require.False(t, gjson.GetBytes(out, "image.image_url").Exists()) + require.Equal(t, "https://example.com/first.png", gjson.GetBytes(out, "images.0.url").String()) + require.False(t, gjson.GetBytes(out, "images.0.image_url").Exists()) + require.Equal(t, "https://example.com/second.png", gjson.GetBytes(out, "images.1.url").String()) +} + func TestNormalizeGrokMediaModelForEndpoint(t *testing.T) { tests := []struct { name string @@ -577,7 +643,8 @@ func TestForwardGrokMediaImagesEditMultipartConvertsToJSON(t *testing.T) { 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,")) + require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "image.url").String(), "data:image/png;base64,")) + require.False(t, gjson.GetBytes(upstream.lastBody, "image.image_url").Exists()) } func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T) { @@ -659,7 +726,7 @@ func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json") require.NoError(t, err) require.Equal(t, "https://xai.test/v1/videos/generations", upstream.lastReq.URL.String()) - require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"animate","image":{"image_url":"data:image/png;base64,aW1n"}}`, string(upstream.lastBody)) + require.JSONEq(t, `{"model":"grok-imagine-video-1.5","prompt":"animate","image":{"url":"data:image/png;base64,aW1n"}}`, string(upstream.lastBody)) require.Equal(t, "video-request-456", result.ResponseID) require.Equal(t, "grok-imagine-video-1.5", result.BillingModel) // 未指定 duration 时按上游默认 8 秒计费。 @@ -705,7 +772,8 @@ func TestForwardGrokMediaOAuthImageToVideoUsesOfficialAPIForLargeBody(t *testing require.Equal(t, xai.DefaultBaseURL+"/videos/generations", upstream.lastReq.URL.String()) require.Empty(t, upstream.lastReq.Header.Get("X-XAI-Token-Auth")) require.Empty(t, upstream.lastReq.Header.Get("x-grok-client-version")) - require.Equal(t, "data:image/png;base64,"+imageData, gjson.GetBytes(upstream.lastBody, "image.image_url").String()) + require.Equal(t, "data:image/png;base64,"+imageData, gjson.GetBytes(upstream.lastBody, "image.url").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "image.image_url").Exists()) } func TestForwardGrokMediaVideoStatusUsesGETWithoutBody(t *testing.T) {