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
+174 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -549,7 +550,7 @@ func TestOpenAIClientImageEditUsesMultipartWithMultipleImages(t *testing.T) {
if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
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)
}
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) {
var received map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+241
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"mime"
@@ -12,6 +13,8 @@ import (
"path"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type GeminiClient struct {
@@ -27,6 +30,20 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
return c.runVeo(ctx, request, apiKey)
}
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)
endpoint := geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey)
if request.Stream {
@@ -92,6 +109,230 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
}, 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 {
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
if operationName == "" {
body, err := geminiVeoBody(request)
body, err := geminiVeoBody(ctx, request)
if err != nil {
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, "")
if prompt == "" {
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)
instance := map[string]any{"prompt": prompt}
if firstFrame.URI != "" {
image, err := geminiVeoInlineImage(firstFrame)
image, err := geminiVeoInlineImage(ctx, firstFrame)
if err != nil {
return nil, err
}
instance["image"] = image
}
if lastFrame.URI != "" {
image, err := geminiVeoInlineImage(lastFrame)
image, err := geminiVeoInlineImage(ctx, lastFrame)
if err != nil {
return nil, err
}
@@ -135,7 +135,7 @@ func geminiVeoBody(request Request) (map[string]any, error) {
if len(references) > 0 {
items := make([]any, 0, len(references))
for _, reference := range references {
image, err := geminiVeoInlineImage(reference)
image, err := geminiVeoInlineImage(ctx, reference)
if err != nil {
return nil, err
}
@@ -340,13 +340,20 @@ func deduplicateGeminiVeoImages(images []geminiVeoImage, exclusions ...string) [
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)
mimeType := strings.TrimSpace(image.MimeType)
data := ""
if parsed != nil {
data = parsed.data
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) {
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
}
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 {
normalized := strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://") || strings.HasPrefix(normalized, "/static/")
+118 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -144,7 +145,7 @@ func TestGeminiVeoBodyMapsImageAndParameters(t *testing.T) {
first := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("first"))
last := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("last"))
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",
"first_frame": first,
"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) {
_, 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") {
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)
seenCorrectionErrors := make(map[string]struct{})
var resp *http.Response
requestClient := httpClient(request.HTTPClient, c.HTTPClient)
for correctionAttempt := 0; ; correctionAttempt++ {
raw, contentType, payloadErr := openAIRequestPayload(endpointKind, body, request.Candidate)
raw, contentType, payloadErr := openAIRequestPayload(ctx, endpointKind, body, request.Candidate)
if payloadErr != nil {
return Response{}, payloadErr
}
@@ -105,7 +106,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, requestErr = httpClient(request.HTTPClient, c.HTTPClient).Do(req)
resp, requestErr = requestClient.Do(req)
if requestErr != nil {
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" {
raw, err := json.Marshal(body)
return raw, "application/json", err
}
if OpenAIImageEditUsesJSONURL(candidate) {
return openAIImageEditJSONPayload(body)
}
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
imageFieldName := openAIImageEditFieldName(candidate)
images := openAIImageEditValues(firstPresent(body["images"], body["image"]))
if len(images) == 0 {
return nil, "", &ClientError{
@@ -272,8 +275,9 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
Retryable: false,
}
}
imageFieldName := openAIImageEditFieldName(candidate, len(images))
for index, value := range images {
contentType, image, err := openAIImageEditPayload(value)
contentType, image, err := openAIImageEditPayload(ctx, value)
if err != nil {
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 {
contentType, image, err := openAIImageEditPayload(mask)
contentType, image, err := openAIImageEditPayload(ctx, mask)
if err != nil {
return nil, "", err
}
@@ -315,10 +319,93 @@ func openAIRequestPayload(endpointKind string, body map[string]any, candidate st
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[]" {
return configured
}
if imageCount > 1 {
return "image[]"
}
parsed, err := url.Parse(strings.TrimSpace(candidate.BaseURL))
if err == nil && strings.EqualFold(parsed.Hostname(), "api.openai.com") {
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) {
case map[string]any:
for _, key := range []string{"data", "b64_json", "base64", "url"} {
if nested := typed[key]; nested != nil {
return openAIImageEditPayload(nested)
return openAIImageEditPayload(ctx, nested)
}
}
case string:
@@ -365,14 +452,8 @@ func openAIImageEditPayload(value any) (string, []byte, error) {
}
contentType = strings.TrimSpace(strings.Split(strings.TrimPrefix(prefix, "data:"), ";")[0])
encoded = payload
} else if strings.Contains(raw, "://") {
return "", nil, &ClientError{
Code: "invalid_parameter",
Message: "OpenAI image edit input must be hydrated before multipart submission",
Param: "image",
StatusCode: http.StatusBadRequest,
Retryable: false,
}
} else if parsed, parseErr := url.Parse(raw); parseErr == nil && parsed.Host != "" && (strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https")) {
return fetchRemoteMediaInputPayload(ctx, raw, 256<<20)
}
image, err := decodeOpenAIImageEditBase64(encoded)
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) {
normalized := strings.Map(func(char rune) rune {
switch char {