mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-08 01:16:47 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user