fix(provider): 修正媒体请求转换与上游错误透传

按上游协议能力延迟处理媒体资源:OpenAI 兼容平台默认使用 multipart,显式配置后才发送 JSON URL;Gemini 官方协议使用 Files API,兼容协议使用内嵌 Base64,并同步覆盖相关媒体客户端。\n\n安全的上游 400/422 原始错误会作为下游 message 返回,同时保留结构化诊断信息和历史任务兼容。\n\n验证:API 全量无缓存测试、go vet、pnpm lint、pnpm test、pnpm build、pnpm openapi、git diff --check。
This commit is contained in:
2026-08-05 00:40:42 +08:00
parent c79c2a7b44
commit ebdb96e7d7
19 changed files with 1163 additions and 93 deletions
+4
View File
@@ -13503,6 +13503,10 @@
"code": { "code": {
"type": "string" "type": "string"
}, },
"details": {
"type": "object",
"additionalProperties": {}
},
"httpStatus": { "httpStatus": {
"type": "integer" "type": "integer"
}, },
+3
View File
@@ -2043,6 +2043,9 @@ definitions:
type: string type: string
code: code:
type: string type: string
details:
additionalProperties: {}
type: object
httpStatus: httpStatus:
type: integer type: integer
message: message:
+174 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -549,7 +550,7 @@ func TestOpenAIClientImageEditUsesMultipartWithMultipleImages(t *testing.T) {
if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") { if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
t.Fatalf("image edit should use multipart/form-data, got %q", contentType) t.Fatalf("image edit should use multipart/form-data, got %q", contentType)
} }
if receivedImageCount != 2 || len(receivedImageFields) != 1 || receivedImageFields[0] != "image" { if receivedImageCount != 2 || len(receivedImageFields) != 1 || receivedImageFields[0] != "image[]" {
t.Fatalf("unexpected multipart image files: fields=%v count=%d", receivedImageFields, receivedImageCount) t.Fatalf("unexpected multipart image files: fields=%v count=%d", receivedImageFields, receivedImageCount)
} }
if receivedFields["model"][0] != "gpt-image-2-vip" || if receivedFields["model"][0] != "gpt-image-2-vip" ||
@@ -568,6 +569,178 @@ func TestOpenAIClientImageEditUsesMultipartWithMultipleImages(t *testing.T) {
} }
} }
func TestOpenAIClientImageEditDownloadsURLWhenBuildingMultipart(t *testing.T) {
imagePayload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
fetchCount := 0
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetchCount++
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imagePayload)
}))
defer imageServer.Close()
var uploaded []byte
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("parse multipart image edit: %v", err)
}
files := r.MultipartForm.File["image"]
if len(files) != 1 {
t.Fatalf("expected one multipart image, got %+v", r.MultipartForm.File)
}
file, err := files[0].Open()
if err != nil {
t.Fatalf("open multipart image: %v", err)
}
defer file.Close()
uploaded, err = io.ReadAll(file)
if err != nil {
t.Fatalf("read multipart image: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []any{map[string]any{"b64_json": "aW1hZ2U="}},
})
}))
defer upstream.Close()
_, err := (OpenAIClient{HTTPClient: upstream.Client()}).Run(context.Background(), Request{
Kind: "images.edits",
ModelType: "image_edit",
Body: map[string]any{
"image": imageServer.URL + "/source.png",
"prompt": "edit it",
},
Candidate: store.RuntimeModelCandidate{
BaseURL: upstream.URL,
Provider: "openai",
ProviderModelName: "gpt-image-compatible",
ModelType: "image_edit",
Credentials: map[string]any{"apiKey": "openai-key"},
},
})
if err != nil {
t.Fatalf("run OpenAI URL multipart image edit: %v", err)
}
if fetchCount != 1 || string(uploaded) != string(imagePayload) {
t.Fatalf("image URL should be fetched once during multipart construction: fetches=%d uploaded=%v", fetchCount, uploaded)
}
}
func TestKelingAndMinimaxMaterializeRemoteMediaInsideClient(t *testing.T) {
payload := []byte("remote media payload")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mpeg")
_, _ = w.Write(payload)
}))
defer server.Close()
encoded, err := kelingImageToBase64(context.Background(), Request{HTTPClient: server.Client()}, server.URL+"/image.png")
if err != nil {
t.Fatalf("materialize Keling image URL: %v", err)
}
if encoded != base64.StdEncoding.EncodeToString(payload) {
t.Fatalf("unexpected Keling image base64: %q", encoded)
}
audio, filename, contentType, err := minimaxVoiceCloneFilePayload(context.Background(), server.Client(), server.URL+"/audio.mp3", "voice_clone")
if err != nil {
t.Fatalf("materialize Minimax audio URL: %v", err)
}
if string(audio) != string(payload) || filename != "voice_clone.mp3" || contentType != "audio/mpeg" {
t.Fatalf("unexpected Minimax audio materialization: filename=%q contentType=%q payload=%q", filename, contentType, audio)
}
}
func TestOpenAIClientImageEditUsesJSONURLsOnlyWhenExplicitlyConfigured(t *testing.T) {
var contentType string
var received map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
contentType = r.Header.Get("Content-Type")
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
t.Fatalf("decode image edit JSON: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []any{map[string]any{"url": "https://cdn.example.com/output.png"}},
})
}))
defer server.Close()
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
Kind: "images.edits",
ModelType: "image_edit",
Model: "compatible-image-edit",
Body: map[string]any{
"images": []any{
"https://cdn.example.com/first.png",
map[string]any{"url": "https://cdn.example.com/second.png"},
},
"mask_image": map[string]any{"image_url": "https://cdn.example.com/mask.png"},
"prompt": "combine the references",
"_metadata": map[string]any{"private": true},
},
Candidate: store.RuntimeModelCandidate{
BaseURL: server.URL,
Provider: "compatible-openai",
SpecType: "openai",
ProviderModelName: "compatible-image-edit-v2",
ModelType: "image_edit",
Credentials: map[string]any{"apiKey": "openai-key"},
PlatformConfig: map[string]any{"imageEditRequestFormat": "json_url"},
},
})
if err != nil {
t.Fatalf("run OpenAI-compatible JSON image edit: %v", err)
}
if !strings.HasPrefix(contentType, "application/json") {
t.Fatalf("explicit JSON URL mode should use application/json, got %q", contentType)
}
images, _ := received["image"].([]any)
if len(images) != 2 || images[0] != "https://cdn.example.com/first.png" || images[1] != "https://cdn.example.com/second.png" {
t.Fatalf("unexpected JSON image URLs: %+v", received)
}
if received["mask"] != "https://cdn.example.com/mask.png" || received["model"] != "compatible-image-edit-v2" {
t.Fatalf("unexpected JSON image edit body: %+v", received)
}
if _, ok := received["images"]; ok {
t.Fatalf("images alias must be normalized to image: %+v", received)
}
if _, ok := received["_metadata"]; ok {
t.Fatalf("internal metadata must not be forwarded: %+v", received)
}
}
func TestOpenAIClientImageEditJSONModeRejectsInlineImageData(t *testing.T) {
_, _, err := openAIRequestPayload(context.Background(), "images.edits", map[string]any{
"image": "data:image/png;base64,aW1hZ2U=",
}, store.RuntimeModelCandidate{PlatformConfig: map[string]any{"imageEditRequestFormat": "json_url"}})
clientErr, ok := err.(*ClientError)
if !ok || clientErr.Code != "invalid_parameter" || clientErr.Param != "image" || clientErr.StatusCode != http.StatusBadRequest {
t.Fatalf("expected non-retryable image URL validation error, got %#v", err)
}
}
func TestOpenAIImageEditFieldNameUsesArraySyntaxForMultipleImages(t *testing.T) {
tests := []struct {
name string
candidate store.RuntimeModelCandidate
imageCount int
want string
}{
{name: "compatible single image", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1"}, imageCount: 1, want: "image"},
{name: "compatible multiple images", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1"}, imageCount: 2, want: "image[]"},
{name: "official single image", candidate: store.RuntimeModelCandidate{BaseURL: "https://api.openai.com/v1"}, imageCount: 1, want: "image[]"},
{name: "explicit scalar override", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1", PlatformConfig: map[string]any{"imageEditMultipartFieldName": "image"}}, imageCount: 2, want: "image"},
{name: "explicit array override", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1", PlatformConfig: map[string]any{"imageEditMultipartFieldName": "image[]"}}, imageCount: 1, want: "image[]"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := openAIImageEditFieldName(test.candidate, test.imageCount); got != test.want {
t.Fatalf("field name = %q, want %q", got, test.want)
}
})
}
}
func TestOpenAIClientImageGenerationUsesSizeWithoutGenericGeometryFields(t *testing.T) { func TestOpenAIClientImageGenerationUsesSizeWithoutGenericGeometryFields(t *testing.T) {
var received map[string]any var received map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+241
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"mime" "mime"
@@ -12,6 +13,8 @@ import (
"path" "path"
"strings" "strings"
"time" "time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
) )
type GeminiClient struct { type GeminiClient struct {
@@ -27,6 +30,20 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
return c.runVeo(ctx, request, apiKey) return c.runVeo(ctx, request, apiKey)
} }
body := geminiBody(request) body := geminiBody(request)
_, nativeContents := request.Body["contents"]
if !nativeContents && GeminiUsesOfficialAPI(request.Candidate) {
var err error
body, err = c.prepareOfficialGeminiImageFiles(ctx, request, apiKey, body)
if err != nil {
return Response{}, err
}
} else if !nativeContents {
var err error
body, err = prepareCompatibleGeminiInlineImages(ctx, body)
if err != nil {
return Response{}, err
}
}
raw, _ := json.Marshal(body) raw, _ := json.Marshal(body)
endpoint := geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey) endpoint := geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey)
if request.Stream { if request.Stream {
@@ -92,6 +109,230 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
}, nil }, nil
} }
// GeminiUsesOfficialAPI distinguishes the native Google Gemini endpoint from
// Gemini-shaped compatibility services and Google's OpenAI compatibility path.
func GeminiUsesOfficialAPI(candidate store.RuntimeModelCandidate) bool {
base := strings.TrimSpace(candidate.BaseURL)
if base == "" {
return true
}
parsed, err := url.Parse(base)
if err != nil || !strings.EqualFold(parsed.Hostname(), "generativelanguage.googleapis.com") {
return false
}
pathValue := strings.ToLower(strings.TrimRight(parsed.Path, "/"))
return pathValue != "/openai" && !strings.HasSuffix(pathValue, "/openai")
}
func (c GeminiClient) prepareOfficialGeminiImageFiles(ctx context.Context, request Request, apiKey string, body map[string]any) (map[string]any, error) {
client := httpClient(request.HTTPClient, c.HTTPClient)
cache := map[string]map[string]any{}
prepared, err := prepareOfficialGeminiImageFileValue(ctx, client, request.Candidate, apiKey, body, cache)
if err != nil {
return nil, err
}
out, _ := prepared.(map[string]any)
if out == nil {
return body, nil
}
return out, nil
}
func prepareOfficialGeminiImageFileValue(ctx context.Context, client *http.Client, candidate store.RuntimeModelCandidate, apiKey string, value any, cache map[string]map[string]any) (any, error) {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for key, item := range typed {
prepared, err := prepareOfficialGeminiImageFileValue(ctx, client, candidate, apiKey, item, cache)
if err != nil {
return nil, err
}
next[key] = prepared
}
for _, key := range []string{"fileData", "file_data"} {
fileData, ok := next[key].(map[string]any)
if !ok {
continue
}
fileURI := firstNonEmptyString(fileData["fileUri"], fileData["file_uri"], fileData["uri"])
mimeType := firstNonEmptyString(fileData["mimeType"], fileData["mime_type"], mimeFromURI(fileURI))
if !requestAssetStringIsHTTPURL(fileURI) || !strings.HasPrefix(strings.ToLower(mimeType), "image/") || geminiOfficialFileURI(fileURI) {
continue
}
cacheKey := fileURI + "\x00" + mimeType
uploaded := cache[cacheKey]
if uploaded == nil {
var err error
uploaded, err = uploadOfficialGeminiImageFile(ctx, client, candidate, apiKey, fileURI, mimeType)
if err != nil {
fallback, fallbackErr := compatibleGeminiInlineImage(ctx, fileURI, mimeType)
if fallbackErr != nil {
return nil, err
}
delete(next, key)
next["inlineData"] = fallback
continue
}
cache[cacheKey] = uploaded
}
next[key] = cloneBody(uploaded)
}
return next, nil
case []any:
next := make([]any, 0, len(typed))
for _, item := range typed {
prepared, err := prepareOfficialGeminiImageFileValue(ctx, client, candidate, apiKey, item, cache)
if err != nil {
return nil, err
}
next = append(next, prepared)
}
return next, nil
default:
return value, nil
}
}
func prepareCompatibleGeminiInlineImages(ctx context.Context, body map[string]any) (map[string]any, error) {
prepared, err := prepareCompatibleGeminiInlineImageValue(ctx, body, map[string]map[string]any{})
if err != nil {
return nil, err
}
out, _ := prepared.(map[string]any)
if out == nil {
return body, nil
}
return out, nil
}
func prepareCompatibleGeminiInlineImageValue(ctx context.Context, value any, cache map[string]map[string]any) (any, error) {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for key, item := range typed {
prepared, err := prepareCompatibleGeminiInlineImageValue(ctx, item, cache)
if err != nil {
return nil, err
}
next[key] = prepared
}
for _, key := range []string{"fileData", "file_data"} {
fileData, ok := next[key].(map[string]any)
if !ok {
continue
}
fileURI := firstNonEmptyString(fileData["fileUri"], fileData["file_uri"], fileData["uri"])
mimeType := firstNonEmptyString(fileData["mimeType"], fileData["mime_type"], mimeFromURI(fileURI))
if !requestAssetStringIsHTTPURL(fileURI) || !strings.HasPrefix(strings.ToLower(mimeType), "image/") {
continue
}
cacheKey := fileURI + "\x00" + mimeType
inline := cache[cacheKey]
if inline == nil {
var err error
inline, err = compatibleGeminiInlineImage(ctx, fileURI, mimeType)
if err != nil {
return nil, err
}
cache[cacheKey] = inline
}
delete(next, key)
next["inlineData"] = cloneBody(inline)
}
return next, nil
case []any:
next := make([]any, 0, len(typed))
for _, item := range typed {
prepared, err := prepareCompatibleGeminiInlineImageValue(ctx, item, cache)
if err != nil {
return nil, err
}
next = append(next, prepared)
}
return next, nil
default:
return value, nil
}
}
func compatibleGeminiInlineImage(ctx context.Context, sourceURL string, declaredMimeType string) (map[string]any, error) {
fetchedMimeType, payload, err := fetchRemoteMediaInputPayload(ctx, sourceURL, 256<<20)
if err != nil {
return nil, err
}
return map[string]any{
"mimeType": geminiMediaMime(firstNonEmptyString(declaredMimeType, fetchedMimeType), "image"),
"data": base64.StdEncoding.EncodeToString(payload),
}, nil
}
func uploadOfficialGeminiImageFile(ctx context.Context, client *http.Client, candidate store.RuntimeModelCandidate, apiKey string, sourceURL string, declaredMimeType string) (map[string]any, error) {
fetchedMimeType, payload, err := fetchRemoteMediaInputPayload(ctx, sourceURL, 256<<20)
if err != nil {
return nil, err
}
mimeType := geminiMediaMime(firstNonEmptyString(declaredMimeType, fetchedMimeType), "image")
base := strings.TrimRight(strings.TrimSpace(candidate.BaseURL), "/")
if base == "" {
base = "https://generativelanguage.googleapis.com"
}
parsed, err := url.Parse(base)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, &ClientError{Code: "invalid_parameter", Message: "invalid official Gemini base URL", Retryable: false}
}
uploadStartURL := parsed.Scheme + "://" + parsed.Host + "/upload/v1beta/files"
metadata, _ := json.Marshal(map[string]any{"file": map[string]any{"display_name": fmt.Sprintf("easyai-image-%d", time.Now().UnixMilli())}})
startRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadStartURL, bytes.NewReader(metadata))
if err != nil {
return nil, err
}
startRequest.Header.Set("x-goog-api-key", apiKey)
startRequest.Header.Set("X-Goog-Upload-Protocol", "resumable")
startRequest.Header.Set("X-Goog-Upload-Command", "start")
startRequest.Header.Set("X-Goog-Upload-Header-Content-Length", fmt.Sprintf("%d", len(payload)))
startRequest.Header.Set("X-Goog-Upload-Header-Content-Type", mimeType)
startRequest.Header.Set("Content-Type", "application/json")
startResponse, err := client.Do(startRequest)
if err != nil {
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if startResponse.StatusCode < http.StatusOK || startResponse.StatusCode >= http.StatusMultipleChoices {
_, responseErr := decodeHTTPResponse(startResponse)
return nil, responseErr
}
uploadURL := strings.TrimSpace(startResponse.Header.Get("X-Goog-Upload-URL"))
_ = startResponse.Body.Close()
if uploadURL == "" {
return nil, &ClientError{Code: "invalid_response", Message: "Gemini Files API did not return an upload URL", Retryable: false}
}
uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
uploadRequest.Header.Set("X-Goog-Upload-Offset", "0")
uploadRequest.Header.Set("X-Goog-Upload-Command", "upload, finalize")
uploadRequest.Header.Set("Content-Type", mimeType)
uploadResponse, err := client.Do(uploadRequest)
if err != nil {
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
result, err := decodeHTTPResponse(uploadResponse)
if err != nil {
return nil, err
}
file := mapFromAny(result["file"])
fileURI := firstNonEmptyString(file["uri"], result["uri"])
if fileURI == "" {
return nil, &ClientError{Code: "invalid_response", Message: "Gemini Files API response did not include file.uri", Retryable: false}
}
return map[string]any{"fileUri": fileURI, "mimeType": firstNonEmptyString(file["mimeType"], file["mime_type"], mimeType)}, nil
}
func geminiOfficialFileURI(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
return err == nil && strings.EqualFold(parsed.Hostname(), "generativelanguage.googleapis.com") && strings.Contains(strings.ToLower(parsed.Path), "/files/")
}
func geminiURL(baseURL string, model string, apiKey string) string { func geminiURL(baseURL string, model string, apiKey string) string {
return geminiActionURL(baseURL, model, "generateContent", apiKey, false) return geminiActionURL(baseURL, model, "generateContent", apiKey, false)
} }
+18 -6
View File
@@ -33,7 +33,7 @@ func (c GeminiClient) runVeo(ctx context.Context, request Request, apiKey string
var wire *WireResponse var wire *WireResponse
if operationName == "" { if operationName == "" {
body, err := geminiVeoBody(request) body, err := geminiVeoBody(ctx, request)
if err != nil { if err != nil {
return Response{}, err return Response{}, err
} }
@@ -107,7 +107,7 @@ func (c GeminiClient) runVeo(ctx context.Context, request Request, apiKey string
} }
} }
func geminiVeoBody(request Request) (map[string]any, error) { func geminiVeoBody(ctx context.Context, request Request) (map[string]any, error) {
prompt := firstNonEmptyPrompt(request.Body, "") prompt := firstNonEmptyPrompt(request.Body, "")
if prompt == "" { if prompt == "" {
return nil, &ClientError{Code: "invalid_parameter", Message: "gemini Veo prompt is required", Param: "prompt", StatusCode: http.StatusBadRequest, Retryable: false} return nil, &ClientError{Code: "invalid_parameter", Message: "gemini Veo prompt is required", Param: "prompt", StatusCode: http.StatusBadRequest, Retryable: false}
@@ -116,14 +116,14 @@ func geminiVeoBody(request Request) (map[string]any, error) {
firstFrame, lastFrame, references := geminiVeoImageInputs(request.Body) firstFrame, lastFrame, references := geminiVeoImageInputs(request.Body)
instance := map[string]any{"prompt": prompt} instance := map[string]any{"prompt": prompt}
if firstFrame.URI != "" { if firstFrame.URI != "" {
image, err := geminiVeoInlineImage(firstFrame) image, err := geminiVeoInlineImage(ctx, firstFrame)
if err != nil { if err != nil {
return nil, err return nil, err
} }
instance["image"] = image instance["image"] = image
} }
if lastFrame.URI != "" { if lastFrame.URI != "" {
image, err := geminiVeoInlineImage(lastFrame) image, err := geminiVeoInlineImage(ctx, lastFrame)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -135,7 +135,7 @@ func geminiVeoBody(request Request) (map[string]any, error) {
if len(references) > 0 { if len(references) > 0 {
items := make([]any, 0, len(references)) items := make([]any, 0, len(references))
for _, reference := range references { for _, reference := range references {
image, err := geminiVeoInlineImage(reference) image, err := geminiVeoInlineImage(ctx, reference)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -340,13 +340,20 @@ func deduplicateGeminiVeoImages(images []geminiVeoImage, exclusions ...string) [
return out return out
} }
func geminiVeoInlineImage(image geminiVeoImage) (map[string]any, error) { func geminiVeoInlineImage(ctx context.Context, image geminiVeoImage) (map[string]any, error) {
parsed := geminiDataURL(image.URI) parsed := geminiDataURL(image.URI)
mimeType := strings.TrimSpace(image.MimeType) mimeType := strings.TrimSpace(image.MimeType)
data := "" data := ""
if parsed != nil { if parsed != nil {
data = parsed.data data = parsed.data
mimeType = firstNonEmptyString(mimeType, parsed.mimeType) mimeType = firstNonEmptyString(mimeType, parsed.mimeType)
} else if requestAssetStringIsHTTPURL(image.URI) {
fetchedMimeType, payload, err := fetchRemoteMediaInputPayload(ctx, image.URI, 256<<20)
if err != nil {
return nil, err
}
data = base64.StdEncoding.EncodeToString(payload)
mimeType = firstNonEmptyString(mimeType, fetchedMimeType)
} else if !requestLikeURL(image.URI) { } else if !requestLikeURL(image.URI) {
data = strings.TrimSpace(image.URI) data = strings.TrimSpace(image.URI)
} }
@@ -368,6 +375,11 @@ func geminiVeoInlineImage(image geminiVeoImage) (map[string]any, error) {
return map[string]any{"bytesBase64Encoded": data, "mimeType": geminiMediaMime(mimeType, "image")}, nil return map[string]any{"bytesBase64Encoded": data, "mimeType": geminiMediaMime(mimeType, "image")}, nil
} }
func requestAssetStringIsHTTPURL(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
return err == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https"))
}
func requestLikeURL(value string) bool { func requestLikeURL(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value)) normalized := strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://") || strings.HasPrefix(normalized, "/static/") return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://") || strings.HasPrefix(normalized, "/static/")
+118 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -144,7 +145,7 @@ func TestGeminiVeoBodyMapsImageAndParameters(t *testing.T) {
first := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("first")) first := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("first"))
last := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("last")) last := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("last"))
reference := "data:image/webp;base64," + base64.StdEncoding.EncodeToString([]byte("reference")) reference := "data:image/webp;base64," + base64.StdEncoding.EncodeToString([]byte("reference"))
body, err := geminiVeoBody(Request{Body: map[string]any{ body, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{
"prompt": "animate", "prompt": "animate",
"first_frame": first, "first_frame": first,
"last_frame": last, "last_frame": last,
@@ -175,8 +176,123 @@ func TestGeminiVeoBodyMapsImageAndParameters(t *testing.T) {
} }
} }
func TestGeminiVeoDownloadsImageURLWhenBuildingOfficialPayload(t *testing.T) {
payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
fetchCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetchCount++
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
body, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{
"prompt": "animate",
"first_frame": server.URL + "/input.png",
"duration": 8,
}})
if err != nil {
t.Fatalf("build Gemini Veo body from URL: %v", err)
}
instance := body["instances"].([]any)[0].(map[string]any)
image := instance["image"].(map[string]any)
if fetchCount != 1 || image["bytesBase64Encoded"] != base64.StdEncoding.EncodeToString(payload) || image["mimeType"] != "image/png" {
t.Fatalf("Veo URL should be fetched during official payload construction: fetches=%d image=%+v", fetchCount, image)
}
}
func TestGeminiOfficialAPIClassification(t *testing.T) {
tests := []struct {
baseURL string
want bool
}{
{baseURL: "", want: true},
{baseURL: "https://generativelanguage.googleapis.com", want: true},
{baseURL: "https://generativelanguage.googleapis.com/v1beta", want: true},
{baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", want: false},
{baseURL: "https://gemini-compatible.example.com/v1beta", want: false},
}
for _, test := range tests {
if got := GeminiUsesOfficialAPI(store.RuntimeModelCandidate{BaseURL: test.baseURL}); got != test.want {
t.Fatalf("GeminiUsesOfficialAPI(%q) = %t, want %t", test.baseURL, got, test.want)
}
}
}
func TestGeminiCompatibleConvertsFileDataURLToInlineBase64(t *testing.T) {
payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
body := map[string]any{"contents": []any{map[string]any{"parts": []any{map[string]any{
"fileData": map[string]any{"fileUri": server.URL + "/source.png", "mimeType": "image/png"},
}}}}}
prepared, err := prepareCompatibleGeminiInlineImages(context.Background(), body)
if err != nil {
t.Fatalf("prepare compatible Gemini inline image: %v", err)
}
contents := prepared["contents"].([]any)
parts := contents[0].(map[string]any)["parts"].([]any)
part := parts[0].(map[string]any)
inline := part["inlineData"].(map[string]any)
if _, exists := part["fileData"]; exists || inline["mimeType"] != "image/png" || inline["data"] != base64.StdEncoding.EncodeToString(payload) {
t.Fatalf("compatible Gemini should use inlineData: %+v", part)
}
}
func TestGeminiOfficialImageUploadUsesFilesAPI(t *testing.T) {
imagePayload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imagePayload)
}))
defer imageServer.Close()
var uploadServer *httptest.Server
startCount := 0
uploadCount := 0
uploadServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upload/v1beta/files":
startCount++
if r.Header.Get("x-goog-api-key") != "gemini-key" || r.Header.Get("X-Goog-Upload-Protocol") != "resumable" || r.Header.Get("X-Goog-Upload-Header-Content-Type") != "image/png" {
t.Fatalf("unexpected Gemini Files start headers: %+v", r.Header)
}
w.Header().Set("X-Goog-Upload-URL", uploadServer.URL+"/upload/session")
w.WriteHeader(http.StatusOK)
case "/upload/session":
uploadCount++
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read Gemini Files upload: %v", err)
}
if string(raw) != string(imagePayload) || r.Header.Get("X-Goog-Upload-Command") != "upload, finalize" {
t.Fatalf("unexpected Gemini Files upload body=%v headers=%+v", raw, r.Header)
}
_ = json.NewEncoder(w).Encode(map[string]any{"file": map[string]any{
"uri": "https://generativelanguage.googleapis.com/v1beta/files/easyai-image",
"mimeType": "image/png",
}})
default:
http.NotFound(w, r)
}
}))
defer uploadServer.Close()
fileData, err := uploadOfficialGeminiImageFile(context.Background(), uploadServer.Client(), store.RuntimeModelCandidate{BaseURL: uploadServer.URL + "/v1beta"}, "gemini-key", imageServer.URL+"/source.png", "image/png")
if err != nil {
t.Fatalf("upload official Gemini image: %v", err)
}
if startCount != 1 || uploadCount != 1 || fileData["fileUri"] != "https://generativelanguage.googleapis.com/v1beta/files/easyai-image" || fileData["mimeType"] != "image/png" {
t.Fatalf("unexpected Gemini Files result: starts=%d uploads=%d fileData=%+v", startCount, uploadCount, fileData)
}
}
func TestGeminiVeoRejectsInvalidDurationBeforeSubmission(t *testing.T) { func TestGeminiVeoRejectsInvalidDurationBeforeSubmission(t *testing.T) {
_, err := geminiVeoBody(Request{Body: map[string]any{"prompt": "test", "duration": 5}}) _, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{"prompt": "test", "duration": 5}})
if err == nil || ErrorCode(err) != "invalid_parameter" || ErrorParam(err) != "duration" || !strings.Contains(err.Error(), "4, 6, or 8") { if err == nil || ErrorCode(err) != "invalid_parameter" || ErrorParam(err) != "duration" || !strings.Contains(err.Error(), "4, 6, or 8") {
t.Fatalf("unexpected invalid duration error: %v", err) t.Fatalf("unexpected invalid duration error: %v", err)
} }
+139 -17
View File
@@ -90,8 +90,9 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
provisionalRules := make([]parameterCorrectionRule, 0, 2) provisionalRules := make([]parameterCorrectionRule, 0, 2)
seenCorrectionErrors := make(map[string]struct{}) seenCorrectionErrors := make(map[string]struct{})
var resp *http.Response var resp *http.Response
requestClient := httpClient(request.HTTPClient, c.HTTPClient)
for correctionAttempt := 0; ; correctionAttempt++ { for correctionAttempt := 0; ; correctionAttempt++ {
raw, contentType, payloadErr := openAIRequestPayload(endpointKind, body, request.Candidate) raw, contentType, payloadErr := openAIRequestPayload(ctx, endpointKind, body, request.Candidate)
if payloadErr != nil { if payloadErr != nil {
return Response{}, payloadErr return Response{}, payloadErr
} }
@@ -105,7 +106,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
if err := notifySubmissionStarted(request); err != nil { if err := notifySubmissionStarted(request); err != nil {
return Response{}, err return Response{}, err
} }
resp, requestErr = httpClient(request.HTTPClient, c.HTTPClient).Do(req) resp, requestErr = requestClient.Do(req)
if requestErr != nil { if requestErr != nil {
return Response{}, &ClientError{Code: "network", Message: requestErr.Error(), Retryable: true} return Response{}, &ClientError{Code: "network", Message: requestErr.Error(), Retryable: true}
} }
@@ -254,14 +255,16 @@ func normalizeOpenAIImageRequestBody(endpointKind string, body map[string]any, o
} }
} }
func openAIRequestPayload(endpointKind string, body map[string]any, candidate store.RuntimeModelCandidate) ([]byte, string, error) { func openAIRequestPayload(ctx context.Context, endpointKind string, body map[string]any, candidate store.RuntimeModelCandidate) ([]byte, string, error) {
if endpointKind != "images.edits" { if endpointKind != "images.edits" {
raw, err := json.Marshal(body) raw, err := json.Marshal(body)
return raw, "application/json", err return raw, "application/json", err
} }
if OpenAIImageEditUsesJSONURL(candidate) {
return openAIImageEditJSONPayload(body)
}
var payload bytes.Buffer var payload bytes.Buffer
writer := multipart.NewWriter(&payload) writer := multipart.NewWriter(&payload)
imageFieldName := openAIImageEditFieldName(candidate)
images := openAIImageEditValues(firstPresent(body["images"], body["image"])) images := openAIImageEditValues(firstPresent(body["images"], body["image"]))
if len(images) == 0 { if len(images) == 0 {
return nil, "", &ClientError{ return nil, "", &ClientError{
@@ -272,8 +275,9 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
Retryable: false, Retryable: false,
} }
} }
imageFieldName := openAIImageEditFieldName(candidate, len(images))
for index, value := range images { for index, value := range images {
contentType, image, err := openAIImageEditPayload(value) contentType, image, err := openAIImageEditPayload(ctx, value)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -282,7 +286,7 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
} }
} }
if mask := firstPresent(body["mask"], body["mask_image"], body["maskImage"]); mask != nil { if mask := firstPresent(body["mask"], body["mask_image"], body["maskImage"]); mask != nil {
contentType, image, err := openAIImageEditPayload(mask) contentType, image, err := openAIImageEditPayload(ctx, mask)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -315,10 +319,93 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
return payload.Bytes(), writer.FormDataContentType(), nil return payload.Bytes(), writer.FormDataContentType(), nil
} }
func openAIImageEditFieldName(candidate store.RuntimeModelCandidate) string { // OpenAIImageEditUsesJSONURL reports whether the platform explicitly opts in to
// JSON URL requests for image edits. Multipart remains the compatibility-safe
// default when the setting is absent or unrecognized.
func OpenAIImageEditUsesJSONURL(candidate store.RuntimeModelCandidate) bool {
format := strings.ToLower(strings.TrimSpace(stringFromAny(candidate.PlatformConfig["imageEditRequestFormat"])))
format = strings.ReplaceAll(format, "-", "_")
format = strings.ReplaceAll(format, " ", "_")
return format == "json" || format == "json_url"
}
func openAIImageEditJSONPayload(body map[string]any) ([]byte, string, error) {
images := openAIImageEditValues(firstPresent(body["images"], body["image"]))
if len(images) == 0 {
return nil, "", &ClientError{
Code: "invalid_parameter",
Message: "image is required",
Param: "image",
StatusCode: http.StatusBadRequest,
Retryable: false,
}
}
imageURLs := make([]any, 0, len(images))
for _, image := range images {
imageURL, err := openAIImageEditURLValue(image, "image")
if err != nil {
return nil, "", err
}
imageURLs = append(imageURLs, imageURL)
}
payload := make(map[string]any, len(body))
for key, value := range body {
switch key {
case "image", "images", "mask", "mask_image", "maskImage":
continue
}
if strings.HasPrefix(key, "_") || value == nil {
continue
}
payload[key] = value
}
if len(imageURLs) == 1 {
payload["image"] = imageURLs[0]
} else {
payload["image"] = imageURLs
}
if mask := firstPresent(body["mask"], body["mask_image"], body["maskImage"]); mask != nil {
maskURL, err := openAIImageEditURLValue(mask, "mask")
if err != nil {
return nil, "", err
}
payload["mask"] = maskURL
}
raw, err := json.Marshal(payload)
return raw, "application/json", err
}
func openAIImageEditURLValue(value any, param string) (string, error) {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"url", "image_url", "imageUrl"} {
if nested := typed[key]; nested != nil {
return openAIImageEditURLValue(nested, param)
}
}
case string:
raw := strings.TrimSpace(typed)
parsed, err := url.Parse(raw)
if err == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) {
return raw, nil
}
}
return "", &ClientError{
Code: "invalid_parameter",
Message: "OpenAI image edit JSON mode requires " + param + " to be an HTTP(S) URL",
Param: param,
StatusCode: http.StatusBadRequest,
Retryable: false,
}
}
func openAIImageEditFieldName(candidate store.RuntimeModelCandidate, imageCount int) string {
if configured := strings.TrimSpace(stringFromAny(candidate.PlatformConfig["imageEditMultipartFieldName"])); configured == "image" || configured == "image[]" { if configured := strings.TrimSpace(stringFromAny(candidate.PlatformConfig["imageEditMultipartFieldName"])); configured == "image" || configured == "image[]" {
return configured return configured
} }
if imageCount > 1 {
return "image[]"
}
parsed, err := url.Parse(strings.TrimSpace(candidate.BaseURL)) parsed, err := url.Parse(strings.TrimSpace(candidate.BaseURL))
if err == nil && strings.EqualFold(parsed.Hostname(), "api.openai.com") { if err == nil && strings.EqualFold(parsed.Hostname(), "api.openai.com") {
return "image[]" return "image[]"
@@ -343,12 +430,12 @@ func openAIImageEditValues(value any) []any {
} }
} }
func openAIImageEditPayload(value any) (string, []byte, error) { func openAIImageEditPayload(ctx context.Context, value any) (string, []byte, error) {
switch typed := value.(type) { switch typed := value.(type) {
case map[string]any: case map[string]any:
for _, key := range []string{"data", "b64_json", "base64", "url"} { for _, key := range []string{"data", "b64_json", "base64", "url"} {
if nested := typed[key]; nested != nil { if nested := typed[key]; nested != nil {
return openAIImageEditPayload(nested) return openAIImageEditPayload(ctx, nested)
} }
} }
case string: case string:
@@ -365,14 +452,8 @@ func openAIImageEditPayload(value any) (string, []byte, error) {
} }
contentType = strings.TrimSpace(strings.Split(strings.TrimPrefix(prefix, "data:"), ";")[0]) contentType = strings.TrimSpace(strings.Split(strings.TrimPrefix(prefix, "data:"), ";")[0])
encoded = payload encoded = payload
} else if strings.Contains(raw, "://") { } else if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) {
return "", nil, &ClientError{ return fetchRemoteMediaInputPayload(ctx, raw, 256<<20)
Code: "invalid_parameter",
Message: "OpenAI image edit input must be hydrated before multipart submission",
Param: "image",
StatusCode: http.StatusBadRequest,
Retryable: false,
}
} }
image, err := decodeOpenAIImageEditBase64(encoded) image, err := decodeOpenAIImageEditBase64(encoded)
if err != nil { if err != nil {
@@ -396,6 +477,47 @@ func openAIImageEditPayload(value any) (string, []byte, error) {
} }
} }
func fetchRemoteMediaInputPayload(ctx context.Context, sourceURL string, maxBytes int64) (string, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return "", nil, &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: false}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: true}
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", nil, &ClientError{
Code: "request_asset_fetch_failed",
Message: resp.Status,
StatusCode: resp.StatusCode,
Retryable: HTTPRetryable(resp.StatusCode),
}
}
if maxBytes <= 0 {
maxBytes = 256 << 20
}
payload, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return "", nil, &ClientError{Code: "request_asset_fetch_failed", Message: err.Error(), Retryable: true}
}
if int64(len(payload)) > maxBytes {
return "", nil, &ClientError{
Code: "invalid_parameter",
Message: "remote media input exceeds the download limit",
Param: "image",
StatusCode: http.StatusBadRequest,
Retryable: false,
}
}
contentType := strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])
if contentType == "" || contentType == "application/octet-stream" {
contentType = strings.TrimSpace(strings.Split(http.DetectContentType(payload), ";")[0])
}
return contentType, payload, nil
}
func decodeOpenAIImageEditBase64(value string) ([]byte, error) { func decodeOpenAIImageEditBase64(value string) ([]byte, error) {
normalized := strings.Map(func(char rune) rune { normalized := strings.Map(func(char rune) rune {
switch char { switch char {
@@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
) )
@@ -191,6 +192,34 @@ func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
} }
} }
func TestEasyAITaskResultResponseForwardsSafeUpstreamParameterMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
legacy := publicerror.Error{
Code: "upstream_invalid_request",
Message: "The upstream service rejected the request parameters.",
Category: "upstream",
Source: "upstream",
HTTPStatus: http.StatusBadRequest,
Version: "v1",
}
response := easyAITaskResultResponse(store.GatewayTask{
ID: "image-error-1",
Kind: "images.edits",
Status: "failed",
ErrorCode: "http_400",
ErrorMessage: raw,
PublicError: &legacy,
})
standard, ok := response["error"].(publicerror.Error)
if !ok {
t.Fatalf("unexpected EasyAI error payload: %+v", response)
}
upstream, _ := standard.Details["upstreamError"].(map[string]any)
if response["message"] != raw || standard.Message != raw || upstream["message"] != raw {
t.Fatalf("EasyAI result did not forward safe upstream diagnostics: %+v", response)
}
}
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) { func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/" base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
task := store.GatewayTask{ task := store.GatewayTask{
+33 -10
View File
@@ -11,9 +11,7 @@ import (
func publicGatewayTask(task store.GatewayTask) store.GatewayTask { func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
if task.Status == "failed" || task.Status == "cancelled" || task.ErrorCode != "" || task.ErrorMessage != "" { if task.Status == "failed" || task.Status == "cancelled" || task.ErrorCode != "" || task.ErrorMessage != "" {
value := publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error), taskErrorHTTPStatus(task), false) value := publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error), taskErrorHTTPStatus(task), false)
if task.PublicError != nil && task.PublicError.Code != "" && task.PublicError.Source != "" { value = mergeStoredPublicError(task.PublicError, value)
value = *task.PublicError
}
value = publicerror.WithIDs(value, task.RequestID, task.ID) value = publicerror.WithIDs(value, task.RequestID, task.ID)
publicerror.Observe(value) publicerror.Observe(value)
task.PublicError = &value task.PublicError = &value
@@ -32,9 +30,7 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
status = http.StatusBadGateway status = http.StatusBadGateway
} }
value := publicerror.FromFields(attempt.ErrorCode, attempt.ErrorMessage, status, attempt.Retryable) value := publicerror.FromFields(attempt.ErrorCode, attempt.ErrorMessage, status, attempt.Retryable)
if attempt.PublicError != nil && attempt.PublicError.Code != "" && attempt.PublicError.Source != "" { value = mergeStoredPublicError(attempt.PublicError, value)
value = *attempt.PublicError
}
value = publicerror.WithIDs(value, attempt.RequestID, task.ID) value = publicerror.WithIDs(value, attempt.RequestID, task.ID)
attempt.PublicError = &value attempt.PublicError = &value
attempt.ErrorCode = value.Code attempt.ErrorCode = value.Code
@@ -44,14 +40,33 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
} }
func publicTaskError(task store.GatewayTask) publicerror.Error { func publicTaskError(task store.GatewayTask) publicerror.Error {
if task.PublicError != nil && task.PublicError.Code != "" && task.PublicError.Source != "" {
return publicerror.WithIDs(*task.PublicError, task.RequestID, task.ID)
}
status := taskErrorHTTPStatus(task) status := taskErrorHTTPStatus(task)
if status <= 0 { if status <= 0 {
status = http.StatusBadGateway status = http.StatusBadGateway
} }
return publicerror.WithIDs(publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error, task.Message), status, false), task.RequestID, task.ID) value := publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error, task.Message), status, false)
value = mergeStoredPublicError(task.PublicError, value)
return publicerror.WithIDs(value, task.RequestID, task.ID)
}
func mergeStoredPublicError(stored *publicerror.Error, derived publicerror.Error) publicerror.Error {
if stored == nil || stored.Code == "" || stored.Source == "" {
return derived
}
value := *stored
if len(value.Details) == 0 && len(derived.Details) > 0 {
value.Details = derived.Details
}
if message := safeDerivedUpstreamMessage(derived); message != "" {
value.Message = message
}
return value
}
func safeDerivedUpstreamMessage(value publicerror.Error) string {
upstream, _ := value.Details["upstreamError"].(map[string]any)
message, _ := upstream["message"].(string)
return strings.TrimSpace(message)
} }
func taskErrorHTTPStatus(task store.GatewayTask) int { func taskErrorHTTPStatus(task store.GatewayTask) int {
@@ -109,6 +124,9 @@ func publicErrorMap(value publicerror.Error) map[string]any {
if taskID := strings.TrimSpace(value.TaskID); taskID != "" { if taskID := strings.TrimSpace(value.TaskID); taskID != "" {
out["taskId"] = taskID out["taskId"] = taskID
} }
if len(value.Details) > 0 {
out["details"] = value.Details
}
return out return out
} }
@@ -146,6 +164,11 @@ func safePublicErrorDetails(details map[string]any, value publicerror.Error, inc
} }
} }
} }
for key, item := range value.Details {
if item != nil {
out[key] = item
}
}
if includePublicError { if includePublicError {
out["publicError"] = value out["publicError"] = value
} }
@@ -47,6 +47,25 @@ func TestPublicHTTPErrorReportsSourceAndPreservesSafeUpstreamStatus(t *testing.T
} }
} }
func TestPublicHTTPErrorIncludesSafeUpstreamParameterMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
recorder := httptest.NewRecorder()
writeProtocolError(recorder, clients.ProtocolOpenAIImages, http.StatusBadRequest, raw, nil, "http_400")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", recorder.Code, recorder.Body.String())
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
errorPayload := requireObject(t, body["error"])
details := requireObject(t, errorPayload["details"])
upstream := requireObject(t, details["upstreamError"])
if errorPayload["message"] != raw || upstream["message"] != raw || upstream["code"] != "http_400" || upstream["statusCode"] != float64(http.StatusBadRequest) {
t.Fatalf("unexpected upstream error details: %+v", body)
}
}
func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) { func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
rawMessage := `404 page not found: {"privateProject":"secret"}` rawMessage := `404 page not found: {"privateProject":"secret"}`
task := store.GatewayTask{ task := store.GatewayTask{
@@ -75,6 +94,40 @@ func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
} }
} }
func TestPublicGatewayTaskForwardsSafeUpstreamParameterMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
legacy := publicerror.Error{
Code: "upstream_invalid_request",
Message: "The upstream service rejected the request parameters.",
Category: "upstream",
Source: "upstream",
HTTPStatus: http.StatusBadRequest,
Version: "v1",
}
task := store.GatewayTask{
ID: "task-parameter-error",
Status: "failed",
ErrorCode: "http_400",
ErrorMessage: raw,
PublicError: &legacy,
Attempts: []store.TaskAttempt{{
AttemptNo: 1,
Status: "failed",
StatusCode: http.StatusBadRequest,
ErrorCode: "http_400",
ErrorMessage: raw,
PublicError: &legacy,
}},
}
public := publicGatewayTask(task)
upstream := requireObject(t, public.PublicError.Details["upstreamError"])
attemptUpstream := requireObject(t, public.Attempts[0].PublicError.Details["upstreamError"])
if public.ErrorMessage != raw || public.Attempts[0].ErrorMessage != raw || upstream["message"] != raw || attemptUpstream["message"] != raw {
t.Fatalf("task response did not forward safe upstream diagnostics: %+v", public)
}
}
func TestTaskErrorHTTPStatusRebuildsLegacySnapshotFromAttempt(t *testing.T) { func TestTaskErrorHTTPStatusRebuildsLegacySnapshotFromAttempt(t *testing.T) {
legacy := publicerror.Error{ legacy := publicerror.Error{
Code: "upstream_request_rejected", Code: "upstream_request_rejected",
+51 -10
View File
@@ -1,6 +1,7 @@
package publicerror package publicerror
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -19,6 +20,7 @@ type Error struct {
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
RequestID string `json:"requestId,omitempty"` RequestID string `json:"requestId,omitempty"`
TaskID string `json:"taskId,omitempty"` TaskID string `json:"taskId,omitempty"`
Details map[string]any `json:"details,omitempty"`
Version string `json:"version"` Version string `json:"version"`
} }
@@ -115,7 +117,7 @@ func mappedError(code string, message string, status int, retryable bool) (Error
return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true
} }
if strings.HasPrefix(code, "http_") || (code == "provider_failed" && status > 0 && status < 500) { if strings.HasPrefix(code, "http_") || (code == "provider_failed" && status > 0 && status < 500) {
return upstreamHTTPError(status), true return upstreamHTTPError(status, originalUpstreamErrorDetail(code, message, status)), true
} }
if status >= 500 && code != "upstream_submission_unknown" && (strings.Contains(code, "upstream") || strings.Contains(code, "provider")) { if status >= 500 && code != "upstream_submission_unknown" && (strings.Contains(code, "upstream") || strings.Contains(code, "provider")) {
return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true return newError("upstream_unavailable", "The upstream service is temporarily unavailable.", "upstream", http.StatusServiceUnavailable, true, "retry"), true
@@ -152,27 +154,66 @@ func newError(code string, message string, category string, status int, retryabl
return Error{Code: code, Message: message, Category: category, Source: errorSource(code, category), HTTPStatus: status, Retryable: retryable, Action: action, Version: Version} return Error{Code: code, Message: message, Category: category, Source: errorSource(code, category), HTTPStatus: status, Retryable: retryable, Action: action, Version: Version}
} }
func upstreamHTTPError(status int) Error { func upstreamHTTPError(status int, details map[string]any) Error {
if status < 400 || status >= 500 { if status < 400 || status >= 500 {
status = http.StatusBadRequest status = http.StatusBadRequest
} }
var value Error
switch status { switch status {
case http.StatusBadRequest: case http.StatusBadRequest:
return newError("upstream_invalid_request", "The upstream service rejected the request parameters.", "upstream", status, false, "fix_request") value = newError("upstream_invalid_request", "The upstream service rejected the request parameters.", "upstream", status, false, "fix_request")
case http.StatusNotFound: case http.StatusNotFound:
return newError("upstream_not_found", "The upstream service could not find the requested resource.", "upstream", status, false, "contact_support") value = newError("upstream_not_found", "The upstream service could not find the requested resource.", "upstream", status, false, "contact_support")
case http.StatusMethodNotAllowed: case http.StatusMethodNotAllowed:
return newError("upstream_method_not_allowed", "The upstream service did not accept the configured request method.", "upstream", status, false, "contact_support") value = newError("upstream_method_not_allowed", "The upstream service did not accept the configured request method.", "upstream", status, false, "contact_support")
case http.StatusConflict: case http.StatusConflict:
return newError("upstream_conflict", "The upstream service reported a request conflict.", "upstream", status, false, "fix_request") value = newError("upstream_conflict", "The upstream service reported a request conflict.", "upstream", status, false, "fix_request")
case http.StatusRequestEntityTooLarge: case http.StatusRequestEntityTooLarge:
return newError("upstream_payload_too_large", "The upstream service rejected the request because its payload was too large.", "upstream", status, false, "fix_request") value = newError("upstream_payload_too_large", "The upstream service rejected the request because its payload was too large.", "upstream", status, false, "fix_request")
case http.StatusUnsupportedMediaType: case http.StatusUnsupportedMediaType:
return newError("upstream_unsupported_media_type", "The upstream service rejected the request media type.", "upstream", status, false, "fix_request") value = newError("upstream_unsupported_media_type", "The upstream service rejected the request media type.", "upstream", status, false, "fix_request")
case http.StatusUnprocessableEntity: case http.StatusUnprocessableEntity:
return newError("upstream_unprocessable_request", "The upstream service could not process the request parameters.", "upstream", status, false, "fix_request") value = newError("upstream_unprocessable_request", "The upstream service could not process the request parameters.", "upstream", status, false, "fix_request")
default: default:
return newError("upstream_request_rejected", "The upstream service rejected the request.", "upstream", status, false, "fix_request") value = newError("upstream_request_rejected", "The upstream service rejected the request.", "upstream", status, false, "fix_request")
}
if message := originalUpstreamMessage(details); message != "" {
value.Message = message
}
value.Details = details
return value
}
func originalUpstreamMessage(details map[string]any) string {
upstream, _ := details["upstreamError"].(map[string]any)
message, _ := upstream["message"].(string)
return strings.TrimSpace(message)
}
func originalUpstreamErrorDetail(code string, message string, status int) map[string]any {
if status != http.StatusBadRequest && status != http.StatusUnprocessableEntity {
return nil
}
message = strings.TrimSpace(strings.ToValidUTF8(message, ""))
if message == "" || sensitiveTransportMessage(message) || json.Valid([]byte(message)) {
return nil
}
lower := strings.ToLower(message)
for _, marker := range []string{"bearer ", "password=", "secret=", "api_key=", "apikey=", "access_token=", "refresh_token=", "sk-"} {
if strings.Contains(lower, marker) {
return nil
}
}
runes := []rune(message)
if len(runes) > 2048 {
message = string(runes[:2048])
}
return map[string]any{
"upstreamError": map[string]any{
"code": strings.TrimSpace(code),
"message": message,
"statusCode": status,
},
} }
} }
@@ -68,6 +68,36 @@ func TestProviderHTTPErrorDoesNotExposeProviderBody(t *testing.T) {
} }
} }
func TestUpstreamParameterErrorPreservesSafeOriginalMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
for _, test := range []struct {
code string
status int
}{
{code: "http_400", status: http.StatusBadRequest},
{code: "http_422", status: http.StatusUnprocessableEntity},
} {
got := FromFields(test.code, raw, test.status, false)
upstream, _ := got.Details["upstreamError"].(map[string]any)
if got.Message != raw || upstream["message"] != raw || upstream["code"] != test.code || upstream["statusCode"] != test.status {
t.Fatalf("%s: safe upstream message was not forwarded: %+v", test.code, got)
}
}
}
func TestUpstreamParameterErrorRejectsOpaqueOrSensitiveDetails(t *testing.T) {
for _, raw := range []string{
`{"error":{"message":"bucket private-a rejected secret-project"}}`,
"invalid api_key=sk-private-value",
"read tcp 10.42.0.1:1234: connection reset by peer",
} {
got := FromFields("http_400", raw, http.StatusBadRequest, false)
if len(got.Details) != 0 || got.Message == raw {
t.Fatalf("unsafe upstream message was exposed for %q: %+v", raw, got)
}
}
}
func TestGatewayRateLimitIsDistinctFromUpstreamRateLimit(t *testing.T) { func TestGatewayRateLimitIsDistinctFromUpstreamRateLimit(t *testing.T) {
gateway := FromFields("gateway_rate_limited", "concurrency limit is saturated and queueing is disabled", http.StatusTooManyRequests, true) gateway := FromFields("gateway_rate_limited", "concurrency limit is saturated and queueing is disabled", http.StatusTooManyRequests, true)
if gateway.Code != "gateway_rate_limited" || gateway.Source != "gateway" || gateway.HTTPStatus != http.StatusTooManyRequests { if gateway.Code != "gateway_rate_limited" || gateway.Source != "gateway" || gateway.HTTPStatus != http.StatusTooManyRequests {
+101 -20
View File
@@ -128,6 +128,15 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
if err != nil { if err != nil {
return nil, err return nil, err
} }
if requestAssetClientMaterializesRemoteMedia(candidate, path, asset) {
assetURL, accessErr := s.requestAssetAccessURL(ctx, asset)
if accessErr != nil {
return nil, accessErr
}
if requestAssetStringIsHTTPURL(assetURL) {
return assetURL, nil
}
}
switch requestAssetHydrationForField(path, asset, candidate) { switch requestAssetHydrationForField(path, asset, candidate) {
case requestAssetHydrateUnsupported: case requestAssetHydrateUnsupported:
return nil, requestAssetUnsupportedInputFormatError(path, asset) return nil, requestAssetUnsupportedInputFormatError(path, asset)
@@ -155,6 +164,9 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
if strings.TrimSpace(assetURL) == "" { if strings.TrimSpace(assetURL) == "" {
return nil, requestAssetExpiredError(asset) return nil, requestAssetExpiredError(asset)
} }
if openAIImageEditRequiresJSONURL(candidate) && imageInputFieldNeedsHydration(path) && !requestAssetURLIsPublic(asset.StorageProvider, assetURL) {
return nil, requestAssetPublicURLRequiredError(path)
}
return assetURL, nil return assetURL, nil
} }
@@ -163,8 +175,17 @@ func (s *Service) hydrateProviderRequestAssetString(ctx context.Context, value s
if raw == "" || !imageInputFieldNeedsHydration(path) { if raw == "" || !imageInputFieldNeedsHydration(path) {
return value, nil return value, nil
} }
if openAIImageEditRequiresJSONURL(candidate) {
if !requestAssetURLIsPublic("", raw) {
return nil, requestAssetPublicURLRequiredError(path)
}
return value, nil
}
if requestAssetClientMaterializesRemoteMedia(candidate, path, store.RequestAsset{URL: raw}) && requestAssetStringIsHTTPURL(raw) {
return value, nil
}
style, ok := requestAssetHydrateUnsupported, false style, ok := requestAssetHydrateUnsupported, false
if geminiVeoRequiresInlineImage(candidate) || openAIImageEditRequiresMultipartBytes(candidate) { if openAIImageEditRequiresMultipartBytes(candidate) {
style, ok = requestAssetHydrateDataURL, true style, ok = requestAssetHydrateDataURL, true
} else { } else {
style, ok = requestAssetCapabilityHydrationForMedia("image", candidate, raw, "") style, ok = requestAssetCapabilityHydrationForMedia("image", candidate, raw, "")
@@ -392,13 +413,22 @@ func requestAssetHydrationForField(path []string, asset store.RequestAsset, cand
return requestAssetHydrateRawBase64 return requestAssetHydrateRawBase64
} }
if candidate.ModelType == "voice_clone" && voiceCloneAudioFieldNeedsHydration(path, asset) { if candidate.ModelType == "voice_clone" && voiceCloneAudioFieldNeedsHydration(path, asset) {
if requestAssetStringIsHTTPURL(asset.URL) {
return requestAssetHydrateURL
}
return requestAssetHydrateDataURL return requestAssetHydrateDataURL
} }
if requestAssetMediaKindForHydration(path, asset) == "image" { if requestAssetMediaKindForHydration(path, asset) == "image" {
if geminiVeoRequiresInlineImage(candidate) { if requestAssetClientMaterializesRemoteMedia(candidate, path, asset) && requestAssetStringIsHTTPURL(asset.URL) {
return requestAssetHydrateDataURL return requestAssetHydrateURL
}
if openAIImageEditRequiresJSONURL(candidate) {
return requestAssetHydrateURL
} }
if openAIImageEditRequiresMultipartBytes(candidate) { if openAIImageEditRequiresMultipartBytes(candidate) {
if requestAssetStringIsHTTPURL(asset.URL) {
return requestAssetHydrateURL
}
return requestAssetHydrateDataURL return requestAssetHydrateDataURL
} }
if style, ok := requestAssetCapabilityHydrationForMedia("image", candidate, asset.URL, asset.StorageProvider); ok { if style, ok := requestAssetCapabilityHydrationForMedia("image", candidate, asset.URL, asset.StorageProvider); ok {
@@ -409,21 +439,63 @@ func requestAssetHydrationForField(path []string, asset store.RequestAsset, cand
if style := configuredRequestAssetMediaURLHydration(candidate, requestAssetMediaURLKind(path)); style != "" { if style := configuredRequestAssetMediaURLHydration(candidate, requestAssetMediaURLKind(path)); style != "" {
return style return style
} }
if providerMediaURLNeedsDataURL(candidate) {
return requestAssetHydrateDataURL
} }
if requestAssetMediaKindForHydration(path, asset) != "" && strings.EqualFold(strings.TrimSpace(asset.StorageProvider), "local_static") {
return requestAssetHydrateDataURL
} }
return requestAssetHydrateURL return requestAssetHydrateURL
} }
func openAIImageEditRequiresMultipartBytes(candidate store.RuntimeModelCandidate) bool { func openAIImageEditRequiresMultipartBytes(candidate store.RuntimeModelCandidate) bool {
return normalizeProviderKey(candidate.Provider) == "openai" && return openAIImageEditCandidate(candidate) && !clients.OpenAIImageEditUsesJSONURL(candidate)
strings.TrimSpace(candidate.ModelType) == "image_edit"
} }
func geminiVeoRequiresInlineImage(candidate store.RuntimeModelCandidate) bool { func openAIImageEditRequiresJSONURL(candidate store.RuntimeModelCandidate) bool {
return normalizeProviderKey(candidate.Provider) == "gemini" && return openAIImageEditCandidate(candidate) && clients.OpenAIImageEditUsesJSONURL(candidate)
strings.Contains(strings.ToLower(strings.TrimSpace(firstNonEmptyString(candidate.ProviderModelName, candidate.ModelName))), "veo") }
func openAIImageEditCandidate(candidate store.RuntimeModelCandidate) bool {
if strings.TrimSpace(candidate.ModelType) != "image_edit" {
return false
}
return normalizeProviderKey(candidate.Provider) == "openai" || normalizeProviderKey(candidate.SpecType) == "openai"
}
func requestAssetClientMaterializesRemoteMedia(candidate store.RuntimeModelCandidate, path []string, asset store.RequestAsset) bool {
mediaKind := requestAssetMediaKindForHydration(path, asset)
if mediaKind == "image" {
if openAIImageEditRequiresMultipartBytes(candidate) || geminiClientMaterializesImages(candidate) || kelingClientMaterializesImages(candidate) {
return true
}
}
return mediaKind == "audio" && strings.TrimSpace(candidate.ModelType) == "voice_clone" && candidateUsesClient(candidate, "minimax")
}
func geminiClientMaterializesImages(candidate store.RuntimeModelCandidate) bool {
return candidateUsesClient(candidate, "gemini", "google_gemini")
}
func kelingClientMaterializesImages(candidate store.RuntimeModelCandidate) bool {
return strings.TrimSpace(candidate.ModelType) != "" && candidateUsesClient(candidate, "keling", "kling")
}
func candidateUsesClient(candidate store.RuntimeModelCandidate, names ...string) bool {
wanted := make(map[string]bool, len(names))
for _, name := range names {
wanted[normalizeProviderKey(name)] = true
}
for _, value := range []string{candidate.SpecType, candidate.Provider} {
if wanted[normalizeProviderKey(value)] {
return true
}
}
if wanted["gemini"] {
provider := normalizeProviderKey(candidate.Provider)
if provider == "gemini_openai" || strings.Contains(strings.ToLower(strings.TrimSpace(candidate.BaseURL)), "generativelanguage.googleapis.com") {
return true
}
}
return false
} }
func requestAssetMediaKindForHydration(path []string, asset store.RequestAsset) string { func requestAssetMediaKindForHydration(path []string, asset store.RequestAsset) string {
@@ -594,6 +666,12 @@ func requestAssetStringLooksURL(value string) bool {
strings.HasPrefix(lower, "/static/uploaded/") strings.HasPrefix(lower, "/static/uploaded/")
} }
func requestAssetStringIsHTTPURL(value string) bool {
raw := strings.TrimSpace(value)
parsed, err := url.Parse(raw)
return err == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https"))
}
func requestAssetURLIsPublic(storageProvider string, value string) bool { func requestAssetURLIsPublic(storageProvider string, value string) bool {
if strings.EqualFold(strings.TrimSpace(storageProvider), "local_static") { if strings.EqualFold(strings.TrimSpace(storageProvider), "local_static") {
return false return false
@@ -701,16 +779,6 @@ func requestAssetHydrationStyleFromString(value string) requestAssetHydrationSty
} }
} }
func providerMediaURLNeedsDataURL(candidate store.RuntimeModelCandidate) bool {
for _, name := range []string{candidate.Provider, candidate.SpecType, candidate.PlatformKey} {
switch normalizeProviderKey(name) {
case "openai", "volces", "volces_openai", "gemini", "vidu":
return true
}
}
return false
}
func normalizeProviderKey(value string) string { func normalizeProviderKey(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value)) normalized := strings.ToLower(strings.TrimSpace(value))
normalized = strings.ReplaceAll(normalized, "-", "_") normalized = strings.ReplaceAll(normalized, "-", "_")
@@ -782,3 +850,16 @@ func requestAssetUnsupportedInputFormatError(path []string, asset store.RequestA
} }
return &clients.ClientError{Code: "request_asset_input_format_unsupported", Message: message, Retryable: false} return &clients.ClientError{Code: "request_asset_input_format_unsupported", Message: message, Retryable: false}
} }
func requestAssetPublicURLRequiredError(path []string) error {
field := strings.Join(path, ".")
if field == "" {
field = "image"
}
return &clients.ClientError{
Code: "request_asset_public_url_required",
Message: "OpenAI image edit JSON mode requires a public HTTP(S) URL for " + field,
Param: field,
Retryable: false,
}
}
+127 -15
View File
@@ -172,7 +172,7 @@ func TestHydrateProviderRequestAssetsConvertsGeminiInlineDataAssetToRawBase64(t
} }
} }
func TestGeminiVeoForcesOfficialInlineImageFormat(t *testing.T) { func TestGeminiVeoPreservesURLUntilOfficialPayloadConstruction(t *testing.T) {
candidate := store.RuntimeModelCandidate{ candidate := store.RuntimeModelCandidate{
Provider: "gemini", Provider: "gemini",
ProviderModelName: "veo-3.1-generate-preview", ProviderModelName: "veo-3.1-generate-preview",
@@ -185,8 +185,77 @@ func TestGeminiVeoForcesOfficialInlineImageFormat(t *testing.T) {
}, },
} }
asset := store.RequestAsset{URL: "https://cdn.example.com/input.png", StorageProvider: "remote", ContentType: "image/png"} asset := store.RequestAsset{URL: "https://cdn.example.com/input.png", StorageProvider: "remote", ContentType: "image/png"}
if got := requestAssetHydrationForField([]string{"image"}, asset, candidate); got != requestAssetHydrateDataURL { if got := requestAssetHydrationForField([]string{"image"}, asset, candidate); got != requestAssetHydrateURL {
t.Fatalf("Gemini Veo must hydrate public image URLs as data URLs, got %q", got) t.Fatalf("Gemini Veo should preserve public URLs until client payload construction, got %q", got)
}
}
func TestRemoteMediaMaterializationBoundaryByClient(t *testing.T) {
imageAsset := store.RequestAsset{URL: "https://cdn.example.com/input.png", StorageProvider: "remote", ContentType: "image/png"}
audioAsset := store.RequestAsset{URL: "https://cdn.example.com/input.mp3", StorageProvider: "remote", ContentType: "audio/mpeg"}
tests := []struct {
name string
candidate store.RuntimeModelCandidate
path []string
asset store.RequestAsset
want bool
}{
{name: "OpenAI multipart image edit", candidate: store.RuntimeModelCandidate{SpecType: "openai", ModelType: "image_edit"}, path: []string{"image"}, asset: imageAsset, want: true},
{name: "OpenAI JSON URL image edit", candidate: store.RuntimeModelCandidate{SpecType: "openai", ModelType: "image_edit", PlatformConfig: map[string]any{"imageEditRequestFormat": "json_url"}}, path: []string{"image"}, asset: imageAsset, want: false},
{name: "Gemini Veo", candidate: store.RuntimeModelCandidate{SpecType: "gemini", ModelType: "image_to_video", ProviderModelName: "veo-3.1-generate-preview"}, path: []string{"image"}, asset: imageAsset, want: true},
{name: "Keling video", candidate: store.RuntimeModelCandidate{SpecType: "keling", ModelType: "image_to_video"}, path: []string{"image"}, asset: imageAsset, want: true},
{name: "Minimax voice clone", candidate: store.RuntimeModelCandidate{SpecType: "minimax", ModelType: "voice_clone"}, path: []string{"audio"}, asset: audioAsset, want: true},
{name: "Volces JSON image", candidate: store.RuntimeModelCandidate{SpecType: "volces", ModelType: "image_edit"}, path: []string{"image"}, asset: imageAsset, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := requestAssetClientMaterializesRemoteMedia(test.candidate, test.path, test.asset); got != test.want {
t.Fatalf("client materializes remote media = %t, want %t", got, test.want)
}
})
}
}
func TestJSONTaskClientDoesNotForcePublicURLToBase64WithoutCapability(t *testing.T) {
asset := store.RequestAsset{URL: "https://cdn.example.com/input.png", StorageProvider: "remote", ContentType: "image/png"}
candidate := store.RuntimeModelCandidate{SpecType: "volces", ModelType: "image_edit"}
if got := requestAssetHydrationForField([]string{"image"}, asset, candidate); got != requestAssetHydrateURL {
t.Fatalf("JSON task client should preserve a public URL when no capability requires base64, got %q", got)
}
}
func TestGeminiURLsArePreservedUntilClientProtocolConstruction(t *testing.T) {
service := &Service{}
compatibleURL := "https://cdn.example.com/compatible.png"
body := map[string]any{"image": compatibleURL}
compatible, err := service.hydrateProviderRequestAssets(context.Background(), body, store.RuntimeModelCandidate{
SpecType: "gemini",
BaseURL: "https://gemini-compatible.example.com/v1beta",
ModelType: "image_edit",
PlatformConfig: map[string]any{
"supportUrlInput": true,
"supportBase64Input": true,
},
})
if err != nil {
t.Fatalf("hydrate compatible Gemini image: %v", err)
}
if compatible["image"] != compatibleURL {
t.Fatalf("compatible Gemini URL should be preserved until client inline conversion, got %q", compatible["image"])
}
officialURL := "https://cdn.example.com/source.png"
official, err := service.hydrateProviderRequestAssets(context.Background(), map[string]any{"image": officialURL}, store.RuntimeModelCandidate{
SpecType: "gemini",
BaseURL: "https://generativelanguage.googleapis.com/v1beta",
ModelType: "image_edit",
})
if err != nil {
t.Fatalf("hydrate official Gemini image: %v", err)
}
if official["image"] != officialURL {
t.Fatalf("official Gemini should preserve URL for Files API upload, got %q", official["image"])
} }
} }
@@ -273,18 +342,12 @@ func TestHydrateProviderRequestAssetsUsesImageCapabilityBase64ForTopLevelImageAs
} }
} }
func TestHydrateProviderRequestAssetsConvertsOpenAIEditImagesForMultipart(t *testing.T) { func TestHydrateProviderRequestAssetsPreservesOpenAIEditURLsUntilMultipartSubmission(t *testing.T) {
payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
service := &Service{} service := &Service{}
body := map[string]any{ body := map[string]any{
"images": []any{ "images": []any{
server.URL + "/first.png", "https://cdn.example.com/first.png",
server.URL + "/second.png", "https://cdn.example.com/second.png",
}, },
} }
@@ -302,9 +365,58 @@ func TestHydrateProviderRequestAssetsConvertsOpenAIEditImagesForMultipart(t *tes
t.Fatalf("hydrate OpenAI edit images: %v", err) t.Fatalf("hydrate OpenAI edit images: %v", err)
} }
images := hydrated["images"].([]any) images := hydrated["images"].([]any)
want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload) if len(images) != 2 || images[0] != "https://cdn.example.com/first.png" || images[1] != "https://cdn.example.com/second.png" {
if len(images) != 2 || stringFromAny(images[0]) != want || stringFromAny(images[1]) != want { t.Fatalf("OpenAI edit URLs should be preserved until multipart construction: %+v", images)
t.Fatalf("OpenAI edit images should be hydrated for multipart: %+v", images) }
}
func TestHydrateProviderRequestAssetsKeepsURLsForExplicitOpenAIJSONMode(t *testing.T) {
service := &Service{}
body := map[string]any{
"images": []any{
"https://cdn.example.com/first.png",
"https://cdn.example.com/second.png",
},
}
candidate := store.RuntimeModelCandidate{
Provider: "compatible-openai",
SpecType: "openai",
ModelType: "image_edit",
Capabilities: map[string]any{
"image_edit": map[string]any{
"support_url_input": false,
"support_base64_input": true,
},
},
PlatformConfig: map[string]any{"imageEditRequestFormat": "json_url"},
}
hydrated, err := service.hydrateProviderRequestAssets(context.Background(), body, candidate)
if err != nil {
t.Fatalf("hydrate OpenAI JSON edit images: %v", err)
}
images := hydrated["images"].([]any)
if len(images) != 2 || images[0] != "https://cdn.example.com/first.png" || images[1] != "https://cdn.example.com/second.png" {
t.Fatalf("OpenAI JSON mode should preserve public URLs: %+v", images)
}
asset := store.RequestAsset{URL: "https://cdn.example.com/asset.png", StorageProvider: "remote", ContentType: "image/png"}
if got := requestAssetHydrationForField([]string{"image"}, asset, candidate); got != requestAssetHydrateURL {
t.Fatalf("explicit JSON mode must override base64 capability hydration, got %q", got)
}
}
func TestHydrateProviderRequestAssetsRejectsInlineDataForExplicitOpenAIJSONMode(t *testing.T) {
service := &Service{}
_, err := service.hydrateProviderRequestAssets(context.Background(), map[string]any{
"image": "data:image/png;base64,aW1hZ2U=",
}, store.RuntimeModelCandidate{
Provider: "openai",
ModelType: "image_edit",
PlatformConfig: map[string]any{"imageEditRequestFormat": "json_url"},
})
clientErr, ok := err.(*clients.ClientError)
if !ok || clientErr.Code != "request_asset_public_url_required" || clientErr.Param != "image" || clientErr.Retryable {
t.Fatalf("expected public URL validation error, got %#v", err)
} }
} }
@@ -53,6 +53,8 @@ curl --fail-with-body \
Use `PATCH /api/admin/platforms/{platformID}` with the complete platform body. Omit `credentials` to preserve existing secrets. A non-empty credentials object is merged into stored credentials. An empty object clears credentials and requires explicit confirmation. Use `PATCH /api/admin/platforms/{platformID}` with the complete platform body. Omit `credentials` to preserve existing secrets. A non-empty credentials object is merged into stored credentials. An empty object clears credentials and requires explicit confirmation.
OpenAI-compatible image edits use `multipart/form-data` by default. Only set `config.imageEditRequestFormat` to `json_url` when the upstream explicitly documents JSON URL input support; in that mode every image and mask must resolve to a public or presigned HTTP(S) URL. Omitting the setting, or setting it to `multipart`, keeps multipart file upload behavior.
## Upsert One Platform Model ## Upsert One Platform Model
`POST /api/admin/platforms/{platformID}/models` inserts or updates the `(platformID, modelName)` binding: `POST /api/admin/platforms/{platformID}/models` inserts or updates the `(platformID, modelName)` binding:
@@ -504,6 +504,16 @@ export function PlatformManagementPanel(props: {
<ToggleField checked={form.supportBase64Input} label="支持 Base64 输入" onChange={(checked) => setForm({ ...form, supportBase64Input: checked })} /> <ToggleField checked={form.supportBase64Input} label="支持 Base64 输入" onChange={(checked) => setForm({ ...form, supportBase64Input: checked })} />
<ToggleField checked={form.supportUrlInput} label="支持 URL 输入" onChange={(checked) => setForm({ ...form, supportUrlInput: checked })} /> <ToggleField checked={form.supportUrlInput} label="支持 URL 输入" onChange={(checked) => setForm({ ...form, supportUrlInput: checked })} />
</div> </div>
<Label>
OpenAI
<Select
value={form.imageEditRequestFormat}
onChange={(event) => setForm({ ...form, imageEditRequestFormat: event.target.value as PlatformWizardForm['imageEditRequestFormat'] })}
>
<option value="multipart">Multipart </option>
<option value="json_url">JSON URL</option>
</Select>
</Label>
</FormSection> </FormSection>
<FormSection icon={<Boxes size={16} />} title={`模型绑定 · ${selectedModels.length}/${availableModels.length}`}> <FormSection icon={<Boxes size={16} />} title={`模型绑定 · ${selectedModels.length}/${availableModels.length}`}>
@@ -1192,6 +1202,7 @@ function platformToForm(
httpProxy: networkProxy.httpProxy, httpProxy: networkProxy.httpProxy,
supportBase64Input: readBoolean(config, 'supportBase64Input', true), supportBase64Input: readBoolean(config, 'supportBase64Input', true),
supportUrlInput: readBoolean(config, 'supportUrlInput', true), supportUrlInput: readBoolean(config, 'supportUrlInput', true),
imageEditRequestFormat: readImageEditRequestFormat(config),
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels), selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels), modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels),
modelNameMappings: platformModelNameMappings(platform, baseModels, currentModels), modelNameMappings: platformModelNameMappings(platform, baseModels, currentModels),
@@ -1210,6 +1221,11 @@ function platformToForm(
}; };
} }
function readImageEditRequestFormat(config: Record<string, unknown>): PlatformWizardForm['imageEditRequestFormat'] {
const value = String(config.imageEditRequestFormat ?? '').trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
return value === 'json' || value === 'json_url' ? 'json_url' : 'multipart';
}
function defaultSelectedModelIds(models: BaseModelCatalogItem[], provider: string) { function defaultSelectedModelIds(models: BaseModelCatalogItem[], provider: string) {
return modelsForProvider(models, provider).map((model) => model.id); return modelsForProvider(models, provider).map((model) => model.id);
} }
@@ -26,6 +26,14 @@ function selectedForm() {
} }
describe('platform model rate limit payload', () => { describe('platform model rate limit payload', () => {
it('uses multipart unless JSON URL image edits are explicitly configured', () => {
const defaultConfig = platformPayload(selectedForm()).config ?? {};
expect(defaultConfig.imageEditRequestFormat).toBeUndefined();
const jsonConfig = platformPayload({ ...selectedForm(), imageEditRequestFormat: 'json_url' }).config ?? {};
expect(jsonConfig.imageEditRequestFormat).toBe('json_url');
});
it('preserves an existing model policy when the edit form was not touched', () => { it('preserves an existing model policy when the edit form was not touched', () => {
const [payload] = platformModelPayloads([baseModel], selectedForm()); const [payload] = platformModelPayloads([baseModel], selectedForm());
@@ -39,6 +39,7 @@ export interface PlatformWizardForm {
httpProxy: string; httpProxy: string;
supportBase64Input: boolean; supportBase64Input: boolean;
supportUrlInput: boolean; supportUrlInput: boolean;
imageEditRequestFormat: 'multipart' | 'json_url';
modelDiscountFactor: string; modelDiscountFactor: string;
modelDiscountFactors: Record<string, string>; modelDiscountFactors: Record<string, string>;
modelNameMappings: Record<string, string>; modelNameMappings: Record<string, string>;
@@ -109,6 +110,7 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
httpProxy: '', httpProxy: '',
supportBase64Input: true, supportBase64Input: true,
supportUrlInput: true, supportUrlInput: true,
imageEditRequestFormat: 'multipart',
modelDiscountFactor: '', modelDiscountFactor: '',
modelDiscountFactors: {}, modelDiscountFactors: {},
modelNameMappings: {}, modelNameMappings: {},
@@ -171,6 +173,7 @@ export function platformPayload(form: PlatformWizardForm, options: { preserveEmp
networkProxy: networkProxyPayload(form), networkProxy: networkProxyPayload(form),
supportBase64Input: form.supportBase64Input, supportBase64Input: form.supportBase64Input,
supportUrlInput: form.supportUrlInput, supportUrlInput: form.supportUrlInput,
...(form.imageEditRequestFormat === 'json_url' ? { imageEditRequestFormat: 'json_url' } : {}),
source: 'gateway-admin', source: 'gateway-admin',
}, },
retryPolicy: { retryPolicy: {
+1
View File
@@ -1264,6 +1264,7 @@ export interface PublicErrorV1 {
retryAfterSeconds?: number; retryAfterSeconds?: number;
requestId?: string; requestId?: string;
taskId?: string; taskId?: string;
details?: Record<string, unknown>;
version: 'v1' | string; version: 'v1' | string;
} }