fix(*): 模型与数据同步 - 完善鉴权、供应商模型和本地持久化能力

This commit is contained in:
ddCat
2026-08-20 22:58:31 +08:00
parent 36666a53a2
commit 11ee5aafec
30 changed files with 688 additions and 203 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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"]`}

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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}
}

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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)
}
}