fix(xai): rewrite image.image_url to image.url before upstream send

Normalize OpenAI-style image refs across images, videos, and Responses so Grok receives {image:{url:...}} instead of image_url.
This commit is contained in:
jellyfish-p
2026-07-17 14:42:12 +08:00
parent 106270bea6
commit f82123a3ef
2 changed files with 189 additions and 5 deletions
+97 -5
View File
@@ -497,13 +497,14 @@ func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth
endpointPath = xaiDefaultImageEndpointPath
}
payload := normalizeXAIImageRefs(req.Payload)
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(req.Payload))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return resp, err
}
applyXAIHeaders(httpReq, auth, token, false, "")
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), req.Payload)
e.recordXAIRequest(ctx, auth, url, httpReq.Header.Clone(), payload)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient = reporter.TrackHTTPClient(httpClient)
@@ -543,15 +544,16 @@ func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth
}
logXAIResolvedBaseURL(ctx, baseURL)
payload := normalizeXAIImageRefs(req.Payload)
method := http.MethodPost
endpointPath := xaiVideosGenerationsPath
var body io.Reader = bytes.NewReader(req.Payload)
var body io.Reader = bytes.NewReader(payload)
switch path := xaiVideoEndpointPath(opts); path {
case xaiVideosGenerationsPath, xaiVideosEditsPath, xaiVideosExtensionsPath:
endpointPath = path
default:
if requestID := strings.TrimSpace(gjson.GetBytes(req.Payload, "request_id").String()); requestID != "" {
if requestID := strings.TrimSpace(gjson.GetBytes(payload, "request_id").String()); requestID != "" {
method = http.MethodGet
endpointPath = xaiVideosPath + "/" + url.PathEscape(requestID)
body = nil
@@ -572,7 +574,7 @@ func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth
httpReq.Header.Set("x-idempotency-key", key)
}
}
e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), req.Payload)
e.recordXAIRequest(ctx, auth, requestURL, httpReq.Header.Clone(), payload)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpResp, err := httpClient.Do(httpReq)
@@ -927,6 +929,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
body = sanitizeXAIInputEncryptedContent(body)
body = normalizeCodexInstructions(body)
body = sanitizeXAIResponsesBody(body, baseModel)
body = normalizeXAIImageRefs(body)
sessionID, errSession := xaiResolveComposerSessionID(ctx, req, opts, baseModel)
if errSession != nil {
@@ -1172,6 +1175,95 @@ func xaiImageEndpointPath(opts cliproxyexecutor.Options) string {
return xaiDefaultImageEndpointPath
}
// normalizeXAIImageRefs rewrites OpenAI-style image object fields to the xAI
// image API shape before the payload is sent upstream:
//
// {"image":{"image_url":"https://..."}} → {"image":{"url":"https://..."}}
//
// Applies to image / images / reference_images anywhere in the JSON tree,
// including nested objects and array items. Does not rewrite chat content
// parts shaped as {"type":"image_url","image_url":{...}}.
func normalizeXAIImageRefs(body []byte) []byte {
if !gjson.ValidBytes(body) {
return body
}
return normalizeXAIImageRefsAt(body, "")
}
func normalizeXAIImageRefsAt(body []byte, path string) []byte {
node := gjson.GetBytes(body, path)
if path == "" {
node = gjson.ParseBytes(body)
}
if !node.Exists() {
return body
}
if node.IsObject() {
node.ForEach(func(key, value gjson.Result) bool {
childPath := key.String()
if path != "" {
childPath = path + "." + key.String()
}
switch key.String() {
case "image":
body = normalizeXAIImageRefAtPath(body, childPath)
case "images", "reference_images":
if value.IsArray() {
for i := range value.Array() {
body = normalizeXAIImageRefAtPath(body, fmt.Sprintf("%s.%d", childPath, i))
}
}
default:
if value.IsObject() || value.IsArray() {
body = normalizeXAIImageRefsAt(body, childPath)
}
}
return true
})
return body
}
if node.IsArray() {
for i := range node.Array() {
childPath := fmt.Sprintf("%d", i)
if path != "" {
childPath = fmt.Sprintf("%s.%d", path, i)
}
body = normalizeXAIImageRefsAt(body, childPath)
}
}
return body
}
func normalizeXAIImageRefAtPath(body []byte, path string) []byte {
ref := gjson.GetBytes(body, path)
if !ref.Exists() || ref.Type != gjson.JSON || !ref.IsObject() {
return body
}
url := strings.TrimSpace(ref.Get("url").String())
if url == "" {
if imageURL := ref.Get("image_url"); imageURL.Exists() {
if imageURL.Type == gjson.String {
url = strings.TrimSpace(imageURL.String())
} else if imageURL.IsObject() {
url = strings.TrimSpace(imageURL.Get("url").String())
}
}
}
if url == "" {
return body
}
// Always emit the xAI field name and drop the OpenAI alias.
body, _ = sjson.SetBytes(body, path+".url", url)
if ref.Get("image_url").Exists() {
body, _ = sjson.DeleteBytes(body, path+".image_url")
}
return body
}
func xaiIsVideoRequest(opts cliproxyexecutor.Options) bool {
return opts.SourceFormat.String() == xaiVideoHandlerType
}
@@ -2250,6 +2250,98 @@ func TestXAIExecutorExecuteImagesUsesEditsEndpoint(t *testing.T) {
}
}
func TestNormalizeXAIImageRefsRewritesImageURLField(t *testing.T) {
t.Parallel()
in := []byte(`{
"model":"grok-imagine-image",
"prompt":"edit",
"image":{"type":"image_url","image_url":"https://example.com/a.png"},
"images":[{"image_url":{"url":"https://example.com/b.png"}},{"url":"https://example.com/c.png","image_url":"https://example.com/ignored.png"}],
"reference_images":[{"image_url":"https://example.com/d.png"}],
"nested":{"image":{"image_url":"https://example.com/e.png"}},
"content":[{"type":"image_url","image_url":{"url":"https://example.com/keep.png"}}]
}`)
out := normalizeXAIImageRefs(in)
if got := gjson.GetBytes(out, "image.url").String(); got != "https://example.com/a.png" {
t.Fatalf("image.url = %q, want https://example.com/a.png; body=%s", got, out)
}
if gjson.GetBytes(out, "image.image_url").Exists() {
t.Fatalf("image.image_url should be removed; body=%s", out)
}
if got := gjson.GetBytes(out, "image.type").String(); got != "image_url" {
t.Fatalf("image.type = %q, want image_url; body=%s", got, out)
}
if got := gjson.GetBytes(out, "images.0.url").String(); got != "https://example.com/b.png" {
t.Fatalf("images.0.url = %q, want https://example.com/b.png; body=%s", got, out)
}
if gjson.GetBytes(out, "images.0.image_url").Exists() {
t.Fatalf("images.0.image_url should be removed; body=%s", out)
}
if got := gjson.GetBytes(out, "images.1.url").String(); got != "https://example.com/c.png" {
t.Fatalf("images.1.url = %q, want existing url kept; body=%s", got, out)
}
if gjson.GetBytes(out, "images.1.image_url").Exists() {
t.Fatalf("images.1.image_url should be removed when url already set; body=%s", out)
}
if got := gjson.GetBytes(out, "reference_images.0.url").String(); got != "https://example.com/d.png" {
t.Fatalf("reference_images.0.url = %q, want https://example.com/d.png; body=%s", got, out)
}
if gjson.GetBytes(out, "reference_images.0.image_url").Exists() {
t.Fatalf("reference_images.0.image_url should be removed; body=%s", out)
}
if got := gjson.GetBytes(out, "nested.image.url").String(); got != "https://example.com/e.png" {
t.Fatalf("nested.image.url = %q, want https://example.com/e.png; body=%s", got, out)
}
if got := gjson.GetBytes(out, "content.0.image_url.url").String(); got != "https://example.com/keep.png" {
t.Fatalf("chat content image_url.url should be preserved, got %q; body=%s", got, out)
}
if gjson.GetBytes(out, "content.0.url").Exists() {
t.Fatalf("chat content parts must not be rewritten to url; body=%s", out)
}
}
func TestXAIExecutorExecuteImagesRewritesImageURLToURL(t *testing.T) {
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var errRead error
gotBody, errRead = io.ReadAll(r.Body)
if errRead != nil {
t.Fatalf("read body: %v", errRead)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"created":123,"data":[{"url":"https://x.ai/image.png"}]}`))
}))
defer server.Close()
exec := NewXAIExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
Provider: "xai",
Attributes: map[string]string{"base_url": server.URL},
Metadata: map[string]any{"access_token": "xai-token"},
}
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: "grok-imagine-image",
Payload: []byte(`{"model":"grok-imagine-image","prompt":"edit","image":{"image_url":"https://example.com/a.png"}}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai-image"),
Metadata: map[string]any{
cliproxyexecutor.RequestPathMetadataKey: "/v1/images/edits",
},
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if got := gjson.GetBytes(gotBody, "image.url").String(); got != "https://example.com/a.png" {
t.Fatalf("upstream image.url = %q, want https://example.com/a.png; body=%s", got, gotBody)
}
if gjson.GetBytes(gotBody, "image.image_url").Exists() {
t.Fatalf("upstream body still has image.image_url: %s", gotBody)
}
}
func TestXAIExecutorExecuteVideosCreate(t *testing.T) {
var gotPath string
var gotMethod string