fix(*): 模型与数据同步 - 完善鉴权、供应商模型和本地持久化能力
This commit is contained in:
@@ -726,14 +726,28 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
capability := "text"
|
||||
var channelModel *model.ChannelModel
|
||||
if !(c.Request.Method == http.MethodGet && path == "/models") {
|
||||
var modelErr error
|
||||
channelModel, modelErr = svc.SystemChannelModel(channel.ID, modelName)
|
||||
if modelErr != nil || channelModel.Protocol == "" {
|
||||
fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置"))
|
||||
return
|
||||
if c.Request.Method == http.MethodGet && systemMiniMaxTaskPath.MatchString(path) && modelName == "" {
|
||||
supported, supportErr := svc.SystemChannelHasProtocol(channel.ID, model.ChannelInterfaceMiniMaxVideo)
|
||||
if supportErr != nil {
|
||||
failService(c, supportErr)
|
||||
return
|
||||
}
|
||||
if !supported {
|
||||
fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权 MiniMax 视频协议"))
|
||||
return
|
||||
}
|
||||
protocol = model.ChannelInterfaceMiniMaxVideo
|
||||
capability = "video"
|
||||
} else {
|
||||
var modelErr error
|
||||
channelModel, modelErr = svc.SystemChannelModel(channel.ID, modelName)
|
||||
if modelErr != nil || channelModel.Protocol == "" {
|
||||
fail(c, http.StatusForbidden, errors.New("当前系统渠道未授权该模型或模型协议尚未配置"))
|
||||
return
|
||||
}
|
||||
protocol = channelModel.Protocol
|
||||
capability = channelModel.Capability
|
||||
}
|
||||
protocol = channelModel.Protocol
|
||||
capability = channelModel.Capability
|
||||
}
|
||||
if err := authorizeSystemProxy(channel, protocol, c.Request.Method, path, c.GetHeader("Content-Type"), body); err != nil {
|
||||
fail(c, http.StatusForbidden, err)
|
||||
|
||||
@@ -32,6 +32,8 @@ var (
|
||||
customArkVideoTaskPath = regexp.MustCompile(`(?:^|/)contents/generations/tasks/[^/]+$`)
|
||||
customGeminiOperationPath = regexp.MustCompile(`(?:^|/)(?:models/[^/]+/)?operations/[^/]+$`)
|
||||
customNovitaTaskResultPath = regexp.MustCompile(`(?:^|/)async/task-result$`)
|
||||
customMiniMaxTaskPath = regexp.MustCompile(`(?:^|/)v2/query/video_generation/[^/]+$`)
|
||||
systemMiniMaxTaskPath = regexp.MustCompile(`^/v2/query/video_generation/[^/]+$`)
|
||||
openAIPostEndpoints = map[string]bool{
|
||||
"/responses": true, "/chat/completions": true, "/images/generations": true, "/images/edits": true,
|
||||
"/audio/speech": true,
|
||||
@@ -71,7 +73,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
|
||||
}
|
||||
allowed := requestPath == "/models" || strings.HasSuffix(requestPath, "/models")
|
||||
if apiFormat == "openai" {
|
||||
allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customVideoContentPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath)
|
||||
allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customVideoContentPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath) || customMiniMaxTaskPath.MatchString(requestPath)
|
||||
} else {
|
||||
allowed = allowed || customGeminiOperationPath.MatchString(requestPath)
|
||||
}
|
||||
@@ -89,7 +91,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
|
||||
}
|
||||
if apiFormat == "openai" {
|
||||
multipartAllowed := mediaType == "multipart/form-data" && (strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/videos"))
|
||||
jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks") || strings.HasSuffix(requestPath, "/video/create"))
|
||||
jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks") || strings.HasSuffix(requestPath, "/video/create") || strings.HasSuffix(requestPath, "/v2/video_generation"))
|
||||
if len(query) != 0 || (!multipartAllowed && !jsonAllowed) {
|
||||
return errors.New("自定义渠道不允许访问该上游接口")
|
||||
}
|
||||
@@ -160,6 +162,23 @@ func authorizeSystemProxy(channel *model.ModelChannel, protocol model.ChannelInt
|
||||
if method == http.MethodGet && requestPath == "/models" {
|
||||
return nil
|
||||
}
|
||||
if protocol == model.ChannelInterfaceMiniMaxVideo {
|
||||
if method == http.MethodGet && systemMiniMaxTaskPath.MatchString(requestPath) {
|
||||
return nil
|
||||
}
|
||||
if method != http.MethodPost || requestPath != "/v2/video_generation" {
|
||||
return errors.New("系统渠道不允许访问该上游接口")
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType != "application/json" {
|
||||
return errors.New("MiniMax 视频生成请求必须使用 application/json")
|
||||
}
|
||||
modelName := proxyRequestModel(contentType, body)
|
||||
if modelName == "" || !channelAllowsModel(channel, modelName) {
|
||||
return errors.New("当前系统渠道未授权该模型")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if protocol == model.ChannelInterfaceGeminiVeo || protocol == model.ChannelInterfaceGeminiImage {
|
||||
matches := geminiGeneratePath.FindStringSubmatch(requestPath)
|
||||
if method != http.MethodPost || len(matches) != 3 {
|
||||
@@ -196,7 +215,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.ChannelInterfaceGeminiImage, 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, model.ChannelInterfaceMiniMaxVideo:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
|
||||
@@ -125,6 +125,26 @@ func TestAuthorizeSystemProxyRestrictsModelProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeSystemProxyMiniMaxVideoCreateAndPoll(t *testing.T) {
|
||||
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["MiniMax-H3"]`}
|
||||
createBody := []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"test"}]}`)
|
||||
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "application/json", createBody); err != nil {
|
||||
t.Fatalf("MiniMax create should be allowed: %v", err)
|
||||
}
|
||||
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodGet, "/v2/query/video_generation/task-1", "", nil); err != nil {
|
||||
t.Fatalf("MiniMax poll should be allowed: %v", err)
|
||||
}
|
||||
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodGet, "/v2/account", "", nil); err == nil {
|
||||
t.Fatal("arbitrary MiniMax GET should be rejected")
|
||||
}
|
||||
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "text/plain", createBody); err == nil {
|
||||
t.Fatal("MiniMax non-JSON create should be rejected")
|
||||
}
|
||||
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "application/json", []byte(`{"model":"unapproved"}`)); err == nil {
|
||||
t.Fatal("unapproved MiniMax model should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeSystemProxyVolcengineArkImageOnlyAllowsGenerations(t *testing.T) {
|
||||
body := []byte(`{"model":"doubao-seedream-test"}`)
|
||||
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["doubao-seedream-test"]`}
|
||||
|
||||
@@ -79,6 +79,7 @@ const (
|
||||
ChannelInterfaceVolcengineJiMengVideo ChannelInterfaceType = "volcengine-jimeng-video"
|
||||
ChannelInterfaceGeminiVeo ChannelInterfaceType = "gemini-veo"
|
||||
ChannelInterfaceNovitaVideo ChannelInterfaceType = "novita-video"
|
||||
ChannelInterfaceMiniMaxVideo ChannelInterfaceType = "minimax-video"
|
||||
|
||||
ApiCallStatusSucceeded ApiCallStatus = "succeeded"
|
||||
ApiCallStatusFailed ApiCallStatus = "failed"
|
||||
|
||||
@@ -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.ChannelInterfaceGeminiImage, 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, model.ChannelInterfaceNovitaVideo, model.ChannelInterfaceMiniMaxVideo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -85,6 +85,21 @@ func (s *Service) SystemChannelModel(channelID string, modelKey string) (*model.
|
||||
return s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(modelKey), "models/"))
|
||||
}
|
||||
|
||||
// SystemChannelHasProtocol 用于没有携带 model 字段的轮询请求:先确认渠道确实配置了该协议,
|
||||
// 再由 handler 按协议限定请求路径,避免用空模型绕过系统渠道授权。
|
||||
func (s *Service) SystemChannelHasProtocol(channelID string, protocol model.ChannelInterfaceType) (bool, error) {
|
||||
items, err := s.repo.ChannelModels(channelID, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Protocol == protocol {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
@@ -517,7 +532,7 @@ func capabilityForProtocol(protocol model.ChannelInterfaceType) string {
|
||||
return "image"
|
||||
case model.ChannelInterfaceOpenAIAudio, model.ChannelInterfaceAsyncAudio:
|
||||
return "audio"
|
||||
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo:
|
||||
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceNovitaVideo, model.ChannelInterfaceMiniMaxVideo:
|
||||
return "video"
|
||||
case model.ChannelInterfaceChatCompletion, model.ChannelInterfaceOpenAIResponse:
|
||||
return "text"
|
||||
|
||||
@@ -228,6 +228,21 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo
|
||||
video.Ratios = []string{"16:9", "9:16", "1:1"}
|
||||
video.Resolutions = []string{"1080p"}
|
||||
video.DefaultResolution = "1080p"
|
||||
case model.ChannelInterfaceMiniMaxVideo:
|
||||
video.References.MaxImages = 9
|
||||
video.References.MaxImageBytes = 30 * 1024 * 1024
|
||||
video.References.MaxVideos = 3
|
||||
video.References.MaxVideoBytes = 50 * 1024 * 1024
|
||||
video.References.MaxVideoDuration = 15
|
||||
video.References.MaxAudios = 3
|
||||
video.References.MaxAudioBytes = 15 * 1024 * 1024
|
||||
video.References.MaxAudioDuration = 15
|
||||
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Default: 5}
|
||||
video.Ratios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||||
video.DefaultRatio = "16:9"
|
||||
video.Resolutions = []string{"768P", "2K"}
|
||||
video.DefaultResolution = "768P"
|
||||
video.Watermark = VideoBooleanConfig{Supported: true, Default: false}
|
||||
}
|
||||
return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
}
|
||||
}
|
||||
if resumedProviderRequestID(ctx) == "" {
|
||||
requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo)
|
||||
requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo)
|
||||
if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1689,6 +1689,9 @@ func audioFormatMimeType(format string) string {
|
||||
}
|
||||
|
||||
func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo) {
|
||||
return runMiniMaxVideoTask(ctx, input)
|
||||
}
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineJiMengVideo) {
|
||||
return runVolcengineJiMengVideoTask(ctx, input)
|
||||
}
|
||||
@@ -1818,6 +1821,159 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
return nil, errors.New("视频生成超时")
|
||||
}
|
||||
|
||||
func runMiniMaxVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(input.Prompt) == "" {
|
||||
return nil, errors.New("MiniMax 视频提示词不能为空")
|
||||
}
|
||||
if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 {
|
||||
return nil, errors.New("MiniMax 视频最多支持 9 张参考图、3 个参考视频和 3 个参考音频")
|
||||
}
|
||||
content := []miniMaxVideoContent{{Type: "text", Text: strings.TrimSpace(input.Prompt)}}
|
||||
for index, image := range input.ReferenceImages {
|
||||
url, err := miniMaxMediaURLValue(image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MiniMax 参考图无效:%w", err)
|
||||
}
|
||||
role := "reference_image"
|
||||
if len(input.ReferenceVideos) == 0 && len(input.ReferenceAudios) == 0 && len(input.ReferenceImages) <= 2 {
|
||||
if index == 0 {
|
||||
role = "first_frame"
|
||||
} else if index == 1 {
|
||||
role = "last_frame"
|
||||
}
|
||||
}
|
||||
content = append(content, miniMaxVideoContent{Type: "image_url", ImageURL: &miniMaxMediaURL{URL: url}, Role: role})
|
||||
}
|
||||
for _, video := range input.ReferenceVideos {
|
||||
url, err := miniMaxMediaURLValue(video)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MiniMax 参考视频无效:%w", err)
|
||||
}
|
||||
content = append(content, miniMaxVideoContent{Type: "video_url", VideoURL: &miniMaxMediaURL{URL: url}, Role: "reference_video"})
|
||||
}
|
||||
for _, audio := range input.ReferenceAudios {
|
||||
url, err := miniMaxMediaURLValue(audio)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MiniMax 参考音频无效:%w", err)
|
||||
}
|
||||
content = append(content, miniMaxVideoContent{Type: "audio_url", AudioURL: &miniMaxMediaURL{URL: url}, Role: "reference_audio"})
|
||||
}
|
||||
watermark := parseBool(input.Config.VideoWatermark, false)
|
||||
frameMode := len(input.ReferenceImages) > 0 && len(input.ReferenceImages) <= 2 && len(input.ReferenceVideos) == 0 && len(input.ReferenceAudios) == 0
|
||||
body := miniMaxVideoRequest{
|
||||
Model: input.Config.Model,
|
||||
Content: content,
|
||||
Resolution: normalizeMiniMaxResolution(input.Config.VQuality),
|
||||
Duration: normalizeMiniMaxDuration(input.Config.VideoSeconds),
|
||||
Ratio: normalizeMiniMaxRatio(input.Config.Size, frameMode),
|
||||
AIGCWatermark: &watermark,
|
||||
}
|
||||
id := resumedProviderRequestID(ctx)
|
||||
if id == "" {
|
||||
var created map[string]interface{}
|
||||
if err := postJSON(ctx, input.Config, "/v2/video_generation", body, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = firstNonEmptyString(stringField(created, "task_id"), stringField(created, "id"))
|
||||
if data, ok := created["data"].(map[string]interface{}); ok {
|
||||
id = firstNonEmptyString(id, stringField(data, "task_id"), stringField(data, "id"))
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
return nil, errors.New("MiniMax 视频接口没有返回任务 ID")
|
||||
}
|
||||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||||
var response map[string]interface{}
|
||||
if err := getJSON(ctx, input.Config, "/v2/query/video_generation/"+url.PathEscape(id), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, _ := response["task"].(map[string]interface{})
|
||||
if task == nil {
|
||||
task = response
|
||||
}
|
||||
status := strings.ToLower(stringField(task, "status"))
|
||||
if status == "succeeded" || status == "completed" {
|
||||
contentValue, _ := task["content"].(map[string]interface{})
|
||||
videoURL := stringField(contentValue, "url")
|
||||
if videoURL == "" {
|
||||
return nil, fmt.Errorf("MiniMax 视频任务 %s 已完成但没有返回视频 URL", id)
|
||||
}
|
||||
data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), input.Config, videoURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MiniMax 视频结果下载失败(任务 %s):%w", id, err)
|
||||
}
|
||||
mimeType = normalizedMediaMimeType(mimeType, data)
|
||||
return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil
|
||||
}
|
||||
if status == "failed" || status == "cancelled" {
|
||||
return nil, fmt.Errorf("MiniMax 视频生成失败(任务 %s):%s", id, miniMaxTaskError(task))
|
||||
}
|
||||
if err := sleepContext(ctx, 2500*time.Millisecond); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("MiniMax 视频生成超时(任务 %s)", id)
|
||||
}
|
||||
|
||||
func miniMaxMediaURLValue(media providerMedia) (string, error) {
|
||||
value := strings.TrimSpace(media.URL)
|
||||
if !isPublicMediaURL(value) {
|
||||
return "", errors.New("参考素材必须使用公网 HTTP(S) URL,请启用对象存储或提供公网素材地址")
|
||||
}
|
||||
if _, err := ValidateOutboundURL(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizeMiniMaxResolution(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "2k" || normalized == "4k" || normalized == "high" || normalized == "1080" || normalized == "1080p" || normalized == "1440p" || normalized == "2160p" {
|
||||
return "2K"
|
||||
}
|
||||
return "768P"
|
||||
}
|
||||
|
||||
func normalizeMiniMaxDuration(value string) int {
|
||||
duration, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
duration = 5
|
||||
}
|
||||
if duration < 4 {
|
||||
return 4
|
||||
}
|
||||
if duration > 15 {
|
||||
return 15
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
func normalizeMiniMaxRatio(value string, frameMode bool) string {
|
||||
if frameMode {
|
||||
return "adaptive"
|
||||
}
|
||||
allowed := map[string]bool{"adaptive": true, "21:9": true, "16:9": true, "4:3": true, "1:1": true, "3:4": true, "9:16": true}
|
||||
value = strings.TrimSpace(value)
|
||||
if allowed[value] {
|
||||
return value
|
||||
}
|
||||
return "16:9"
|
||||
}
|
||||
|
||||
func miniMaxTaskError(task map[string]interface{}) string {
|
||||
if value, ok := task["error"].(map[string]interface{}); ok {
|
||||
message := stringField(value, "message")
|
||||
code := stringField(value, "code")
|
||||
if message != "" && code != "" {
|
||||
return code + ":" + message
|
||||
}
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return "上游返回失败"
|
||||
}
|
||||
|
||||
func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) {
|
||||
if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 {
|
||||
return nil, errors.New("Gemini Veo 当前只支持 1 张起始图,不支持参考视频或音频")
|
||||
@@ -2530,7 +2686,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, "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},
|
||||
"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, "minimax-video": true},
|
||||
"audio": {"openai-audio": true, "async-audio": true},
|
||||
}
|
||||
if allowed[mode] != nil && !allowed[mode][interfaceType] {
|
||||
@@ -3269,7 +3425,13 @@ func providerRequestKind(method string, path string) string {
|
||||
|
||||
func apiURL(baseURL string, path string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if strings.HasSuffix(base, "/v1") || strings.HasSuffix(base, "/v1beta") || strings.HasSuffix(base, "/api/v3") || strings.HasSuffix(base, "/api/plan/v3") {
|
||||
if strings.HasPrefix(path, "/v2/") {
|
||||
if strings.HasSuffix(base, "/v2") {
|
||||
return base + strings.TrimPrefix(path, "/v2")
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") || strings.HasSuffix(base, "/v1beta") || strings.HasSuffix(base, "/v2") || strings.HasSuffix(base, "/api/v3") || strings.HasSuffix(base, "/api/plan/v3") {
|
||||
return base + path
|
||||
}
|
||||
return base + "/v1" + path
|
||||
|
||||
@@ -130,3 +130,25 @@ type seedanceAgentPlanRequest struct {
|
||||
GenerateAudio *bool `json:"generate_audio,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
}
|
||||
|
||||
type miniMaxVideoRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content []miniMaxVideoContent `json:"content"`
|
||||
Resolution string `json:"resolution"`
|
||||
Duration int `json:"duration"`
|
||||
Ratio string `json:"ratio,omitempty"`
|
||||
AIGCWatermark *bool `json:"aigc_watermark,omitempty"`
|
||||
}
|
||||
|
||||
type miniMaxVideoContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *miniMaxMediaURL `json:"image_url,omitempty"`
|
||||
VideoURL *miniMaxMediaURL `json:"video_url,omitempty"`
|
||||
AudioURL *miniMaxMediaURL `json:"audio_url,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type miniMaxMediaURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
@@ -1880,3 +1880,78 @@ func TestRunNovitaVideoTaskReturnsFailureReason(t *testing.T) {
|
||||
t.Fatalf("runVideoTask() error = %v, want reason in message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMiniMaxVideoTaskCreatesPollsAndDownloads(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
paths := make([]string, 0, 3)
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.Method+" "+r.URL.Path)
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /v2/video_generation":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Errorf("Authorization = %q", got)
|
||||
}
|
||||
var body miniMaxVideoRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if body.Model != "MiniMax-H3" || body.Resolution != "768P" || body.Duration != 5 || body.Ratio != "16:9" || len(body.Content) != 1 || body.Content[0].Text != "make it move" {
|
||||
t.Errorf("body = %#v", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"task_id":"minimax-task-1"}`))
|
||||
case "GET /v2/query/video_generation/minimax-task-1":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"task":{"id":"minimax-task-1","status":"succeeded","content":{"url":"` + server.URL + `/video.mp4"}}}`))
|
||||
case "GET /video.mp4":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := runVideoTask(context.Background(), canvasGenerationInput{
|
||||
Mode: "video",
|
||||
Prompt: "make it move",
|
||||
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video", VideoSeconds: "5", VQuality: "720", Size: "16:9"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runVideoTask() error = %v", err)
|
||||
}
|
||||
video := result["video"].(map[string]interface{})
|
||||
if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
|
||||
t.Fatalf("video = %#v", video)
|
||||
}
|
||||
if got := strings.Join(paths, ","); got != "POST /v2/video_generation,GET /v2/query/video_generation/minimax-task-1,GET /video.mp4" {
|
||||
t.Fatalf("paths = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMiniMaxVideoTaskReturnsFailureReason(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /v2/video_generation":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"task_id":"minimax-task-2"}`))
|
||||
case "GET /v2/query/video_generation/minimax-task-2":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"task":{"status":"failed","error":{"code":"1026","message":"content violates policy"}}}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := runVideoTask(context.Background(), canvasGenerationInput{
|
||||
Mode: "video",
|
||||
Prompt: "make it move",
|
||||
Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", InterfaceType: "minimax-video"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "1026:content violates policy") {
|
||||
t.Fatalf("runVideoTask() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user