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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user