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