feat(platform): 创作能力整合 - 新增 Gemini Images 图片请求协议
This commit is contained in:
1 parent
a23b4dda93
commit
778551127c
17 files changed
+489
-74
No files matched your search
@@ -805,7 +805,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
}
|
||||
service.ApplyOutboundHeaders(upstreamReq, channelHeaders)
|
||||
service.ApplyDefaultOutboundHeaders(upstreamReq)
|
||||
if protocol == model.ChannelInterfaceGeminiVeo {
|
||||
if protocol == model.ChannelInterfaceGeminiVeo || protocol == model.ChannelInterfaceGeminiImage {
|
||||
upstreamReq.Header.Set("x-goog-api-key", channel.APIKey)
|
||||
} else {
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+channel.APIKey)
|
||||
@@ -867,7 +867,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID string, capability string, protocol model.ChannelInterfaceType, method string, path string, target string, body []byte, contentType string, status model.ApiCallStatus, statusCode int, duration time.Duration, errorText string, concurrencyLimit int) model.ApiCallLog {
|
||||
requestKind := "create"
|
||||
apiFormat := "openai"
|
||||
if protocol == model.ChannelInterfaceGeminiVeo {
|
||||
if protocol == model.ChannelInterfaceGeminiVeo || protocol == model.ChannelInterfaceGeminiImage {
|
||||
apiFormat = "gemini"
|
||||
}
|
||||
if method == http.MethodGet {
|
||||
|
||||
@@ -160,7 +160,7 @@ func authorizeSystemProxy(channel *model.ModelChannel, protocol model.ChannelInt
|
||||
if method == http.MethodGet && requestPath == "/models" {
|
||||
return nil
|
||||
}
|
||||
if protocol == model.ChannelInterfaceGeminiVeo {
|
||||
if protocol == model.ChannelInterfaceGeminiVeo || protocol == model.ChannelInterfaceGeminiImage {
|
||||
matches := geminiGeneratePath.FindStringSubmatch(requestPath)
|
||||
if method != http.MethodPost || len(matches) != 3 {
|
||||
return errors.New("系统渠道不允许访问该上游接口")
|
||||
@@ -196,7 +196,7 @@ func interfaceAllowsProxyPath(interfaceType model.ChannelInterfaceType, requestP
|
||||
return requestPath == "/images/generations"
|
||||
case model.ChannelInterfaceOpenAIAudio:
|
||||
return requestPath == "/audio/speech"
|
||||
case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceNovitaVideo:
|
||||
case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceGeminiImage, model.ChannelInterfaceNovitaVideo:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
|
||||
@@ -64,6 +64,7 @@ const (
|
||||
ChannelInterfaceGrokImage ChannelInterfaceType = "grok-image"
|
||||
ChannelInterfaceVolcengineArkImage ChannelInterfaceType = "volcengine-ark-image"
|
||||
ChannelInterfaceVolcengineJiMengImage ChannelInterfaceType = "volcengine-jimeng-image"
|
||||
ChannelInterfaceGeminiImage ChannelInterfaceType = "gemini-image"
|
||||
ChannelInterfaceOpenAIAudio ChannelInterfaceType = "openai-audio"
|
||||
ChannelInterfaceAsyncAudio ChannelInterfaceType = "async-audio"
|
||||
ChannelInterfaceNewAPIVideo ChannelInterfaceType = "newapi"
|
||||
|
||||
@@ -816,7 +816,7 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
|
||||
|
||||
func validChannelInterfaceType(value model.ChannelInterfaceType) bool {
|
||||
switch value {
|
||||
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
|
||||
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse, model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceGeminiImage, model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -510,7 +510,7 @@ func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error {
|
||||
|
||||
func capabilityForProtocol(protocol model.ChannelInterfaceType) string {
|
||||
switch protocol {
|
||||
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage:
|
||||
case model.ChannelInterfaceOpenAIImage, model.ChannelInterfaceGrokImage, model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceGeminiImage:
|
||||
return "image"
|
||||
case model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio:
|
||||
return "audio"
|
||||
|
||||
@@ -143,6 +143,14 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab
|
||||
image.TransparentBackground.Supported = false
|
||||
image.ResponseFormat.Supported = false
|
||||
image.OutputFormat.Supported = false
|
||||
case model.ChannelInterfaceGeminiImage:
|
||||
image.References.MaskSupported = false
|
||||
// Gemini Images uses imageConfig.aspectRatio, not the OpenAI-style pixel size field.
|
||||
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"auto", "1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"}, Default: "1:1", AllowCustom: false}
|
||||
image.TransparentBackground.Supported = false
|
||||
image.ResponseFormat.Supported = false
|
||||
image.OutputFormat.Supported = false
|
||||
image.MaxOutputs = 4
|
||||
}
|
||||
if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") {
|
||||
image.References.MaxImages = 0
|
||||
|
||||
@@ -210,7 +210,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Config.APIFormat == "gemini" && input.Config.InterfaceType != string(model.ChannelInterfaceGeminiVeo) {
|
||||
if input.Config.APIFormat == "gemini" && input.Config.InterfaceType != string(model.ChannelInterfaceGeminiVeo) && input.Config.InterfaceType != string(model.ChannelInterfaceGeminiImage) {
|
||||
return nil, errors.New("后端任务队列暂不支持 Gemini 调用格式,请使用 OpenAI 兼容渠道")
|
||||
}
|
||||
if strings.TrimSpace(input.Config.BaseURL) == "" || strings.TrimSpace(input.Config.APIKey) == "" || strings.TrimSpace(input.Config.Model) == "" {
|
||||
@@ -621,7 +621,7 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
|
||||
}
|
||||
config.InterfaceType = string(channelModel.Protocol)
|
||||
// 模型协议是实际请求契约;混合渠道中鉴权格式也必须随模型协议切换。
|
||||
if config.InterfaceType == string(model.ChannelInterfaceGeminiVeo) {
|
||||
if config.InterfaceType == string(model.ChannelInterfaceGeminiVeo) || config.InterfaceType == string(model.ChannelInterfaceGeminiImage) {
|
||||
config.APIFormat = "gemini"
|
||||
} else if config.InterfaceType != "" {
|
||||
config.APIFormat = "openai"
|
||||
@@ -674,6 +674,9 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkImage) {
|
||||
return runVolcengineArkImageTask(ctx, input)
|
||||
}
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceGeminiImage) {
|
||||
return runGeminiImageTask(ctx, input)
|
||||
}
|
||||
var payload imageResponse
|
||||
if input.Mask != nil {
|
||||
// 蒙版编辑是强校验写路径:协议能力不明确时必须失败,不能静默退化为整图重绘。
|
||||
@@ -753,6 +756,143 @@ func runImageTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
return map[string]interface{}{"mode": "image", "images": images}, nil
|
||||
}
|
||||
|
||||
func runGeminiImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
if input.Mask != nil {
|
||||
return nil, errors.New("Gemini Images 不支持蒙版编辑,请移除蒙版后重试")
|
||||
}
|
||||
if len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
|
||||
return nil, errors.New("Gemini Images 不支持参考视频或音频")
|
||||
}
|
||||
|
||||
parts := make([]geminiImageContentPart, 0, 1+len(input.ReferenceImages))
|
||||
if prompt := strings.TrimSpace(input.Prompt); prompt != "" {
|
||||
parts = append(parts, geminiImageContentPart{Text: prompt})
|
||||
}
|
||||
for _, image := range input.ReferenceImages {
|
||||
raw, mimeType, err := geminiImageBytes(image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 Gemini Images 参考图失败:%w", err)
|
||||
}
|
||||
parts = append(parts, geminiImageContentPart{InlineData: &geminiImageInlineData{MIMEType: mimeType, Data: base64.StdEncoding.EncodeToString(raw)}})
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return nil, errors.New("Gemini Images 请求缺少提示词或参考图")
|
||||
}
|
||||
|
||||
body := geminiImageRequest{
|
||||
Contents: []geminiImageContent{{Role: "user", Parts: parts}},
|
||||
GenerationConfig: geminiImageGenerationConfig{
|
||||
ResponseModalities: []string{"TEXT", "IMAGE"},
|
||||
ImageConfig: geminiImageConfigFor(input.Config),
|
||||
},
|
||||
}
|
||||
if systemPrompt := strings.TrimSpace(input.Config.SystemPrompt); systemPrompt != "" {
|
||||
body.SystemInstruction = &geminiImageContent{Parts: []geminiImageContentPart{{Text: systemPrompt}}}
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
path := "/models/" + url.PathEscape(input.Config.Model) + ":generateContent"
|
||||
if err := postGeminiJSON(ctx, input.Config, path, body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
images, err := geminiImageDataURLs(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"mode": "image", "images": images}, nil
|
||||
}
|
||||
|
||||
func geminiImageConfigFor(config providerConfig) *geminiImageConfig {
|
||||
imageConfig := &geminiImageConfig{}
|
||||
size := strings.TrimSpace(config.Size)
|
||||
if size != "" && size != "auto" && strings.Count(size, ":") == 1 {
|
||||
imageConfig.AspectRatio = size
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(config.Quality)) {
|
||||
case "low", "1k":
|
||||
imageConfig.ImageSize = "1K"
|
||||
case "medium", "2k":
|
||||
imageConfig.ImageSize = "2K"
|
||||
case "high", "4k":
|
||||
imageConfig.ImageSize = "4K"
|
||||
}
|
||||
if imageConfig.AspectRatio == "" && imageConfig.ImageSize == "" {
|
||||
return nil
|
||||
}
|
||||
return imageConfig
|
||||
}
|
||||
|
||||
func geminiImageBytes(media providerMedia) ([]byte, string, error) {
|
||||
raw, mimeType, err := mediaBytes(media)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, "", errors.New("参考图片数据为空")
|
||||
}
|
||||
detected := strings.TrimSpace(strings.Split(http.DetectContentType(raw), ";")[0])
|
||||
if !strings.HasPrefix(strings.ToLower(mimeType), "image/") {
|
||||
if !strings.HasPrefix(strings.ToLower(detected), "image/") {
|
||||
return nil, "", fmt.Errorf("参考图片 MIME 类型无效:%s", defaultString(mimeType, detected))
|
||||
}
|
||||
mimeType = detected
|
||||
} else if !strings.HasPrefix(strings.ToLower(detected), "image/") {
|
||||
// 以实际字节签名为准,避免错误的 data URL MIME 把非图片内容伪装成图片。
|
||||
return nil, "", fmt.Errorf("参考图片内容不是有效图片:%s", defaultString(detected, mimeType))
|
||||
}
|
||||
return raw, mimeType, nil
|
||||
}
|
||||
|
||||
func geminiImageDataURLs(payload map[string]interface{}) ([]map[string]string, error) {
|
||||
if errorValue, ok := payload["error"].(map[string]interface{}); ok {
|
||||
if message := stringField(errorValue, "message"); message != "" {
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
}
|
||||
candidates, _ := payload["candidates"].([]interface{})
|
||||
images := make([]map[string]string, 0)
|
||||
for _, candidateValue := range candidates {
|
||||
candidate, _ := candidateValue.(map[string]interface{})
|
||||
content, _ := candidate["content"].(map[string]interface{})
|
||||
parts, _ := content["parts"].([]interface{})
|
||||
for _, partValue := range parts {
|
||||
part, _ := partValue.(map[string]interface{})
|
||||
inlineData, _ := part["inlineData"].(map[string]interface{})
|
||||
if inlineData == nil {
|
||||
inlineData, _ = part["inline_data"].(map[string]interface{})
|
||||
}
|
||||
if inlineData != nil {
|
||||
data := strings.TrimSpace(stringField(inlineData, "data"))
|
||||
mimeType := firstNonEmptyString(stringField(inlineData, "mimeType"), stringField(inlineData, "mime_type"))
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(mimeType), "image/") {
|
||||
return nil, fmt.Errorf("Gemini Images 返回了非图片 MIME 类型:%s", mimeType)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Gemini Images 返回的图片数据无效:%w", err)
|
||||
}
|
||||
images = append(images, map[string]string{"dataUrl": dataURL(mimeType, decoded)})
|
||||
continue
|
||||
}
|
||||
fileData, _ := part["fileData"].(map[string]interface{})
|
||||
if fileData != nil {
|
||||
if fileURL := firstNonEmptyString(stringField(fileData, "fileUri"), stringField(fileData, "file_uri")); fileURL != "" {
|
||||
images = append(images, map[string]string{"dataUrl": fileURL})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return nil, errors.New("Gemini Images 接口没有返回图片")
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func runGrokImageTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
body, path, err := grokImageRequestBody(input)
|
||||
if err != nil {
|
||||
@@ -2257,7 +2397,7 @@ func validateGenerationInterface(mode string, interfaceType string) error {
|
||||
}
|
||||
allowed := map[string]map[string]bool{
|
||||
"text": {"chat-completion": true, "openai-response": true},
|
||||
"image": {"openai-image": true, "grok-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true},
|
||||
"image": {"openai-image": true, "grok-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true, "gemini-image": true},
|
||||
"video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true, "novita-video": true},
|
||||
"audio": {"openai-audio": true, "async-audio": true},
|
||||
}
|
||||
|
||||
@@ -90,6 +90,37 @@ type geminiVeoParameters struct {
|
||||
SampleCount int `json:"sampleCount"`
|
||||
}
|
||||
|
||||
type geminiImageRequest struct {
|
||||
Contents []geminiImageContent `json:"contents"`
|
||||
SystemInstruction *geminiImageContent `json:"systemInstruction,omitempty"`
|
||||
GenerationConfig geminiImageGenerationConfig `json:"generationConfig"`
|
||||
}
|
||||
|
||||
type geminiImageContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiImageContentPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiImageContentPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *geminiImageInlineData `json:"inlineData,omitempty"`
|
||||
}
|
||||
|
||||
type geminiImageInlineData struct {
|
||||
MIMEType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type geminiImageGenerationConfig struct {
|
||||
ResponseModalities []string `json:"responseModalities"`
|
||||
ImageConfig *geminiImageConfig `json:"imageConfig,omitempty"`
|
||||
}
|
||||
|
||||
type geminiImageConfig struct {
|
||||
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||
ImageSize string `json:"imageSize,omitempty"`
|
||||
}
|
||||
|
||||
type seedanceAgentPlanRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content []map[string]interface{} `json:"content"`
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8="
|
||||
const testGeminiReferenceImageDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
|
||||
func TestWriteMediaPartSanitizesFilenameAndSetsMimeType(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
@@ -236,6 +237,86 @@ func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGeminiImageTaskUsesInlineDataAndImageConfig(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1beta/models/gemini-test:generateContent" {
|
||||
t.Errorf("path = %q, want /v1beta/models/gemini-test:generateContent", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("x-goog-api-key"); got != "test-key" {
|
||||
t.Errorf("x-goog-api-key = %q, want test-key", got)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
contents, ok := body["contents"].([]interface{})
|
||||
if !ok || len(contents) != 1 {
|
||||
t.Fatalf("contents = %#v", body["contents"])
|
||||
}
|
||||
content, _ := contents[0].(map[string]interface{})
|
||||
parts, ok := content["parts"].([]interface{})
|
||||
if !ok || len(parts) != 2 {
|
||||
t.Fatalf("parts = %#v", content["parts"])
|
||||
}
|
||||
textPart, _ := parts[0].(map[string]interface{})
|
||||
if textPart["text"] != "edit this image" {
|
||||
t.Errorf("text part = %#v", textPart)
|
||||
}
|
||||
imagePart, _ := parts[1].(map[string]interface{})
|
||||
inlineData, _ := imagePart["inlineData"].(map[string]interface{})
|
||||
if inlineData["mimeType"] != "image/png" || inlineData["data"] != "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" {
|
||||
t.Errorf("inlineData = %#v", inlineData)
|
||||
}
|
||||
generationConfig, _ := body["generationConfig"].(map[string]interface{})
|
||||
modalities, _ := generationConfig["responseModalities"].([]interface{})
|
||||
if !reflect.DeepEqual(modalities, []interface{}{"TEXT", "IMAGE"}) {
|
||||
t.Errorf("responseModalities = %#v", modalities)
|
||||
}
|
||||
imageConfig, _ := generationConfig["imageConfig"].(map[string]interface{})
|
||||
if imageConfig["aspectRatio"] != "16:9" || imageConfig["imageSize"] != "4K" {
|
||||
t.Errorf("imageConfig = %#v", imageConfig)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := runImageTask(context.Background(), canvasGenerationInput{
|
||||
Prompt: "edit this image",
|
||||
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", APIFormat: "gemini", Model: "gemini-test", InterfaceType: "gemini-image", Size: "16:9", Quality: "high"},
|
||||
ReferenceImages: []providerMedia{{DataURL: testGeminiReferenceImageDataURL}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runImageTask() error = %v", err)
|
||||
}
|
||||
images, _ := result["images"].([]map[string]string)
|
||||
if len(images) != 1 || images[0]["dataUrl"] != testReferenceImageDataURL {
|
||||
t.Fatalf("images = %#v", result["images"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGeminiImageTaskRejectsInvalidReferenceBeforeRequest(t *testing.T) {
|
||||
called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
http.Error(w, "unexpected upstream request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := runImageTask(context.Background(), canvasGenerationInput{
|
||||
Prompt: "edit this image",
|
||||
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", APIFormat: "gemini", Model: "gemini-test", InterfaceType: "gemini-image"},
|
||||
ReferenceImages: []providerMedia{{DataURL: "data:text/plain;base64,aGVsbG8="}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 Gemini Images 参考图失败") || !strings.Contains(err.Error(), "MIME 类型无效") {
|
||||
t.Fatalf("runImageTask() error = %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("invalid reference image must be rejected before upstream request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunImageTaskOmitsAutomaticQualityAndSize(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in new issue
Block a user