diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index edb37e73..3443bc9a 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -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 } diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index aa15c29e..892276fe 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -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