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:
@@ -13503,6 +13503,10 @@
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"httpStatus": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
@@ -2043,6 +2043,9 @@ definitions:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
httpStatus:
|
||||
type: integer
|
||||
message:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
|
||||
"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) {
|
||||
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
|
||||
task := store.GatewayTask{
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
|
||||
if task.Status == "failed" || task.Status == "cancelled" || task.ErrorCode != "" || task.ErrorMessage != "" {
|
||||
value := publicerror.FromFields(task.ErrorCode, firstNonEmpty(task.ErrorMessage, task.Error), taskErrorHTTPStatus(task), false)
|
||||
if task.PublicError != nil && task.PublicError.Code != "" && task.PublicError.Source != "" {
|
||||
value = *task.PublicError
|
||||
}
|
||||
value = mergeStoredPublicError(task.PublicError, value)
|
||||
value = publicerror.WithIDs(value, task.RequestID, task.ID)
|
||||
publicerror.Observe(value)
|
||||
task.PublicError = &value
|
||||
@@ -32,9 +30,7 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
value := publicerror.FromFields(attempt.ErrorCode, attempt.ErrorMessage, status, attempt.Retryable)
|
||||
if attempt.PublicError != nil && attempt.PublicError.Code != "" && attempt.PublicError.Source != "" {
|
||||
value = *attempt.PublicError
|
||||
}
|
||||
value = mergeStoredPublicError(attempt.PublicError, value)
|
||||
value = publicerror.WithIDs(value, attempt.RequestID, task.ID)
|
||||
attempt.PublicError = &value
|
||||
attempt.ErrorCode = value.Code
|
||||
@@ -44,14 +40,33 @@ func publicGatewayTask(task store.GatewayTask) store.GatewayTask {
|
||||
}
|
||||
|
||||
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)
|
||||
if status <= 0 {
|
||||
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 {
|
||||
@@ -109,6 +124,9 @@ func publicErrorMap(value publicerror.Error) map[string]any {
|
||||
if taskID := strings.TrimSpace(value.TaskID); taskID != "" {
|
||||
out["taskId"] = taskID
|
||||
}
|
||||
if len(value.Details) > 0 {
|
||||
out["details"] = value.Details
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
rawMessage := `404 page not found: {"privateProject":"secret"}`
|
||||
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) {
|
||||
legacy := publicerror.Error{
|
||||
Code: "upstream_request_rejected",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package publicerror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,17 +10,18 @@ import (
|
||||
const Version = "v1"
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Category string `json:"category"`
|
||||
Source string `json:"source"`
|
||||
HTTPStatus int `json:"httpStatus"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Action string `json:"action"`
|
||||
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Category string `json:"category"`
|
||||
Source string `json:"source"`
|
||||
HTTPStatus int `json:"httpStatus"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Action string `json:"action"`
|
||||
RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func FromFields(code string, message string, status int, retryable bool) Error {
|
||||
@@ -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
|
||||
}
|
||||
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")) {
|
||||
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}
|
||||
}
|
||||
|
||||
func upstreamHTTPError(status int) Error {
|
||||
func upstreamHTTPError(status int, details map[string]any) Error {
|
||||
if status < 400 || status >= 500 {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
var value Error
|
||||
switch status {
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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) {
|
||||
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 {
|
||||
|
||||
@@ -128,6 +128,15 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
|
||||
if err != nil {
|
||||
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) {
|
||||
case requestAssetHydrateUnsupported:
|
||||
return nil, requestAssetUnsupportedInputFormatError(path, asset)
|
||||
@@ -155,6 +164,9 @@ func (s *Service) hydrateProviderRequestAssetRef(ctx context.Context, ref map[st
|
||||
if strings.TrimSpace(assetURL) == "" {
|
||||
return nil, requestAssetExpiredError(asset)
|
||||
}
|
||||
if openAIImageEditRequiresJSONURL(candidate) && imageInputFieldNeedsHydration(path) && !requestAssetURLIsPublic(asset.StorageProvider, assetURL) {
|
||||
return nil, requestAssetPublicURLRequiredError(path)
|
||||
}
|
||||
return assetURL, nil
|
||||
}
|
||||
|
||||
@@ -163,8 +175,17 @@ func (s *Service) hydrateProviderRequestAssetString(ctx context.Context, value s
|
||||
if raw == "" || !imageInputFieldNeedsHydration(path) {
|
||||
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
|
||||
if geminiVeoRequiresInlineImage(candidate) || openAIImageEditRequiresMultipartBytes(candidate) {
|
||||
if openAIImageEditRequiresMultipartBytes(candidate) {
|
||||
style, ok = requestAssetHydrateDataURL, true
|
||||
} else {
|
||||
style, ok = requestAssetCapabilityHydrationForMedia("image", candidate, raw, "")
|
||||
@@ -392,13 +413,22 @@ func requestAssetHydrationForField(path []string, asset store.RequestAsset, cand
|
||||
return requestAssetHydrateRawBase64
|
||||
}
|
||||
if candidate.ModelType == "voice_clone" && voiceCloneAudioFieldNeedsHydration(path, asset) {
|
||||
if requestAssetStringIsHTTPURL(asset.URL) {
|
||||
return requestAssetHydrateURL
|
||||
}
|
||||
return requestAssetHydrateDataURL
|
||||
}
|
||||
if requestAssetMediaKindForHydration(path, asset) == "image" {
|
||||
if geminiVeoRequiresInlineImage(candidate) {
|
||||
return requestAssetHydrateDataURL
|
||||
if requestAssetClientMaterializesRemoteMedia(candidate, path, asset) && requestAssetStringIsHTTPURL(asset.URL) {
|
||||
return requestAssetHydrateURL
|
||||
}
|
||||
if openAIImageEditRequiresJSONURL(candidate) {
|
||||
return requestAssetHydrateURL
|
||||
}
|
||||
if openAIImageEditRequiresMultipartBytes(candidate) {
|
||||
if requestAssetStringIsHTTPURL(asset.URL) {
|
||||
return requestAssetHydrateURL
|
||||
}
|
||||
return requestAssetHydrateDataURL
|
||||
}
|
||||
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 != "" {
|
||||
return style
|
||||
}
|
||||
if providerMediaURLNeedsDataURL(candidate) {
|
||||
return requestAssetHydrateDataURL
|
||||
}
|
||||
}
|
||||
if requestAssetMediaKindForHydration(path, asset) != "" && strings.EqualFold(strings.TrimSpace(asset.StorageProvider), "local_static") {
|
||||
return requestAssetHydrateDataURL
|
||||
}
|
||||
return requestAssetHydrateURL
|
||||
}
|
||||
|
||||
func openAIImageEditRequiresMultipartBytes(candidate store.RuntimeModelCandidate) bool {
|
||||
return normalizeProviderKey(candidate.Provider) == "openai" &&
|
||||
strings.TrimSpace(candidate.ModelType) == "image_edit"
|
||||
return openAIImageEditCandidate(candidate) && !clients.OpenAIImageEditUsesJSONURL(candidate)
|
||||
}
|
||||
|
||||
func geminiVeoRequiresInlineImage(candidate store.RuntimeModelCandidate) bool {
|
||||
return normalizeProviderKey(candidate.Provider) == "gemini" &&
|
||||
strings.Contains(strings.ToLower(strings.TrimSpace(firstNonEmptyString(candidate.ProviderModelName, candidate.ModelName))), "veo")
|
||||
func openAIImageEditRequiresJSONURL(candidate store.RuntimeModelCandidate) bool {
|
||||
return openAIImageEditCandidate(candidate) && clients.OpenAIImageEditUsesJSONURL(candidate)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -594,6 +666,12 @@ func requestAssetStringLooksURL(value string) bool {
|
||||
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 {
|
||||
if strings.EqualFold(strings.TrimSpace(storageProvider), "local_static") {
|
||||
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 {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
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}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestHydrateProviderRequestAssetsConvertsGeminiInlineDataAssetToRawBase64(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiVeoForcesOfficialInlineImageFormat(t *testing.T) {
|
||||
func TestGeminiVeoPreservesURLUntilOfficialPayloadConstruction(t *testing.T) {
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
Provider: "gemini",
|
||||
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"}
|
||||
if got := requestAssetHydrationForField([]string{"image"}, asset, candidate); got != requestAssetHydrateDataURL {
|
||||
t.Fatalf("Gemini Veo must hydrate public image URLs as data URLs, got %q", got)
|
||||
if got := requestAssetHydrationForField([]string{"image"}, asset, candidate); got != requestAssetHydrateURL {
|
||||
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) {
|
||||
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()
|
||||
func TestHydrateProviderRequestAssetsPreservesOpenAIEditURLsUntilMultipartSubmission(t *testing.T) {
|
||||
service := &Service{}
|
||||
body := map[string]any{
|
||||
"images": []any{
|
||||
server.URL + "/first.png",
|
||||
server.URL + "/second.png",
|
||||
"https://cdn.example.com/first.png",
|
||||
"https://cdn.example.com/second.png",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -302,9 +365,58 @@ func TestHydrateProviderRequestAssetsConvertsOpenAIEditImagesForMultipart(t *tes
|
||||
t.Fatalf("hydrate OpenAI edit images: %v", err)
|
||||
}
|
||||
images := hydrated["images"].([]any)
|
||||
want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload)
|
||||
if len(images) != 2 || stringFromAny(images[0]) != want || stringFromAny(images[1]) != want {
|
||||
t.Fatalf("OpenAI edit images should be hydrated for multipart: %+v", images)
|
||||
if len(images) != 2 || images[0] != "https://cdn.example.com/first.png" || images[1] != "https://cdn.example.com/second.png" {
|
||||
t.Fatalf("OpenAI edit URLs should be preserved until multipart construction: %+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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
`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.supportUrlInput} label="支持 URL 输入" onChange={(checked) => setForm({ ...form, supportUrlInput: checked })} />
|
||||
</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 icon={<Boxes size={16} />} title={`模型绑定 · ${selectedModels.length}/${availableModels.length}`}>
|
||||
@@ -1192,6 +1202,7 @@ function platformToForm(
|
||||
httpProxy: networkProxy.httpProxy,
|
||||
supportBase64Input: readBoolean(config, 'supportBase64Input', true),
|
||||
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
||||
imageEditRequestFormat: readImageEditRequestFormat(config),
|
||||
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
|
||||
modelDiscountFactors: platformModelDiscountFactors(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) {
|
||||
return modelsForProvider(models, provider).map((model) => model.id);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ function selectedForm() {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const [payload] = platformModelPayloads([baseModel], selectedForm());
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface PlatformWizardForm {
|
||||
httpProxy: string;
|
||||
supportBase64Input: boolean;
|
||||
supportUrlInput: boolean;
|
||||
imageEditRequestFormat: 'multipart' | 'json_url';
|
||||
modelDiscountFactor: string;
|
||||
modelDiscountFactors: Record<string, string>;
|
||||
modelNameMappings: Record<string, string>;
|
||||
@@ -109,6 +110,7 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
||||
httpProxy: '',
|
||||
supportBase64Input: true,
|
||||
supportUrlInput: true,
|
||||
imageEditRequestFormat: 'multipart',
|
||||
modelDiscountFactor: '',
|
||||
modelDiscountFactors: {},
|
||||
modelNameMappings: {},
|
||||
@@ -171,6 +173,7 @@ export function platformPayload(form: PlatformWizardForm, options: { preserveEmp
|
||||
networkProxy: networkProxyPayload(form),
|
||||
supportBase64Input: form.supportBase64Input,
|
||||
supportUrlInput: form.supportUrlInput,
|
||||
...(form.imageEditRequestFormat === 'json_url' ? { imageEditRequestFormat: 'json_url' } : {}),
|
||||
source: 'gateway-admin',
|
||||
},
|
||||
retryPolicy: {
|
||||
|
||||
@@ -1264,6 +1264,7 @@ export interface PublicErrorV1 {
|
||||
retryAfterSeconds?: number;
|
||||
requestId?: string;
|
||||
taskId?: string;
|
||||
details?: Record<string, unknown>;
|
||||
version: 'v1' | string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user