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