fix(provider): 修正媒体请求转换与上游错误透传

按上游协议能力延迟处理媒体资源:OpenAI 兼容平台默认使用 multipart,显式配置后才发送 JSON URL;Gemini 官方协议使用 Files API,兼容协议使用内嵌 Base64,并同步覆盖相关媒体客户端。\n\n安全的上游 400/422 原始错误会作为下游 message 返回,同时保留结构化诊断信息和历史任务兼容。\n\n验证:API 全量无缓存测试、go vet、pnpm lint、pnpm test、pnpm build、pnpm openapi、git diff --check。
This commit is contained in:
2026-08-05 00:40:42 +08:00
parent c79c2a7b44
commit ebdb96e7d7
19 changed files with 1163 additions and 93 deletions
+118 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -144,7 +145,7 @@ func TestGeminiVeoBodyMapsImageAndParameters(t *testing.T) {
first := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("first"))
last := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("last"))
reference := "data:image/webp;base64," + base64.StdEncoding.EncodeToString([]byte("reference"))
body, err := geminiVeoBody(Request{Body: map[string]any{
body, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{
"prompt": "animate",
"first_frame": first,
"last_frame": last,
@@ -175,8 +176,123 @@ func TestGeminiVeoBodyMapsImageAndParameters(t *testing.T) {
}
}
func TestGeminiVeoDownloadsImageURLWhenBuildingOfficialPayload(t *testing.T) {
payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
fetchCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetchCount++
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
body, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{
"prompt": "animate",
"first_frame": server.URL + "/input.png",
"duration": 8,
}})
if err != nil {
t.Fatalf("build Gemini Veo body from URL: %v", err)
}
instance := body["instances"].([]any)[0].(map[string]any)
image := instance["image"].(map[string]any)
if fetchCount != 1 || image["bytesBase64Encoded"] != base64.StdEncoding.EncodeToString(payload) || image["mimeType"] != "image/png" {
t.Fatalf("Veo URL should be fetched during official payload construction: fetches=%d image=%+v", fetchCount, image)
}
}
func TestGeminiOfficialAPIClassification(t *testing.T) {
tests := []struct {
baseURL string
want bool
}{
{baseURL: "", want: true},
{baseURL: "https://generativelanguage.googleapis.com", want: true},
{baseURL: "https://generativelanguage.googleapis.com/v1beta", want: true},
{baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", want: false},
{baseURL: "https://gemini-compatible.example.com/v1beta", want: false},
}
for _, test := range tests {
if got := GeminiUsesOfficialAPI(store.RuntimeModelCandidate{BaseURL: test.baseURL}); got != test.want {
t.Fatalf("GeminiUsesOfficialAPI(%q) = %t, want %t", test.baseURL, got, test.want)
}
}
}
func TestGeminiCompatibleConvertsFileDataURLToInlineBase64(t *testing.T) {
payload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(payload)
}))
defer server.Close()
body := map[string]any{"contents": []any{map[string]any{"parts": []any{map[string]any{
"fileData": map[string]any{"fileUri": server.URL + "/source.png", "mimeType": "image/png"},
}}}}}
prepared, err := prepareCompatibleGeminiInlineImages(context.Background(), body)
if err != nil {
t.Fatalf("prepare compatible Gemini inline image: %v", err)
}
contents := prepared["contents"].([]any)
parts := contents[0].(map[string]any)["parts"].([]any)
part := parts[0].(map[string]any)
inline := part["inlineData"].(map[string]any)
if _, exists := part["fileData"]; exists || inline["mimeType"] != "image/png" || inline["data"] != base64.StdEncoding.EncodeToString(payload) {
t.Fatalf("compatible Gemini should use inlineData: %+v", part)
}
}
func TestGeminiOfficialImageUploadUsesFilesAPI(t *testing.T) {
imagePayload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(imagePayload)
}))
defer imageServer.Close()
var uploadServer *httptest.Server
startCount := 0
uploadCount := 0
uploadServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/upload/v1beta/files":
startCount++
if r.Header.Get("x-goog-api-key") != "gemini-key" || r.Header.Get("X-Goog-Upload-Protocol") != "resumable" || r.Header.Get("X-Goog-Upload-Header-Content-Type") != "image/png" {
t.Fatalf("unexpected Gemini Files start headers: %+v", r.Header)
}
w.Header().Set("X-Goog-Upload-URL", uploadServer.URL+"/upload/session")
w.WriteHeader(http.StatusOK)
case "/upload/session":
uploadCount++
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read Gemini Files upload: %v", err)
}
if string(raw) != string(imagePayload) || r.Header.Get("X-Goog-Upload-Command") != "upload, finalize" {
t.Fatalf("unexpected Gemini Files upload body=%v headers=%+v", raw, r.Header)
}
_ = json.NewEncoder(w).Encode(map[string]any{"file": map[string]any{
"uri": "https://generativelanguage.googleapis.com/v1beta/files/easyai-image",
"mimeType": "image/png",
}})
default:
http.NotFound(w, r)
}
}))
defer uploadServer.Close()
fileData, err := uploadOfficialGeminiImageFile(context.Background(), uploadServer.Client(), store.RuntimeModelCandidate{BaseURL: uploadServer.URL + "/v1beta"}, "gemini-key", imageServer.URL+"/source.png", "image/png")
if err != nil {
t.Fatalf("upload official Gemini image: %v", err)
}
if startCount != 1 || uploadCount != 1 || fileData["fileUri"] != "https://generativelanguage.googleapis.com/v1beta/files/easyai-image" || fileData["mimeType"] != "image/png" {
t.Fatalf("unexpected Gemini Files result: starts=%d uploads=%d fileData=%+v", startCount, uploadCount, fileData)
}
}
func TestGeminiVeoRejectsInvalidDurationBeforeSubmission(t *testing.T) {
_, err := geminiVeoBody(Request{Body: map[string]any{"prompt": "test", "duration": 5}})
_, err := geminiVeoBody(context.Background(), Request{Body: map[string]any{"prompt": "test", "duration": 5}})
if err == nil || ErrorCode(err) != "invalid_parameter" || ErrorParam(err) != "duration" || !strings.Contains(err.Error(), "4, 6, or 8") {
t.Fatalf("unexpected invalid duration error: %v", err)
}