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