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
+174 -1
View File
@@ -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) {