fix(grok): normalize media reference image payloads

This commit is contained in:
Heatherm Huang
2026-07-17 11:51:08 +08:00
parent bc2244c83f
commit 456c619379
2 changed files with 145 additions and 9 deletions
+74 -6
View File
@@ -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
@@ -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) {