diff --git a/constant/task.go b/constant/task.go index ecccf4dfe119..799bf2938801 100644 --- a/constant/task.go +++ b/constant/task.go @@ -5,6 +5,7 @@ type TaskPlatform string const ( TaskPlatformSuno TaskPlatform = "suno" TaskPlatformMidjourney = "mj" + TaskPlatformMiniMaxV2 = "minimax-video-v2" ) const ( diff --git a/controller/relay.go b/controller/relay.go index 8dccfe76dddd..c4eaae83319e 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -596,7 +596,7 @@ func RelayTask(c *gin.Context) { ModelRatio: relayInfo.PriceData.ModelRatio, OtherRatios: relayInfo.PriceData.OtherRatios(), OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice, + PerCallBilling: !result.AdjustBillingOnComplete && (common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice), } task.Quota = result.Quota task.Data = result.TaskData diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 3735b6ad22ac..2189c95924c7 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -82,3 +82,7 @@ type TaskAdaptor interface { type OpenAIVideoConverter interface { ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) } + +type CompletionBillingAdaptor interface { + UseCompletionBilling() +} diff --git a/relay/channel/minimax/adaptor_test.go b/relay/channel/minimax/adaptor_test.go index 41a70423f942..c47ccf13e519 100644 --- a/relay/channel/minimax/adaptor_test.go +++ b/relay/channel/minimax/adaptor_test.go @@ -13,8 +13,13 @@ import ( "github.com/QuantumNous/new-api/relaykit/dto" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" ) +func TestModelListIncludesMiniMaxH3Video(t *testing.T) { + require.Contains(t, (&Adaptor{}).GetModelList(), "MiniMax-H3") +} + func TestGetRequestURLForImageGeneration(t *testing.T) { t.Parallel() diff --git a/relay/channel/minimax/constants.go b/relay/channel/minimax/constants.go index efdab04c34c6..e7ed3381b3f2 100644 --- a/relay/channel/minimax/constants.go +++ b/relay/channel/minimax/constants.go @@ -23,6 +23,7 @@ var ModelList = []string{ "MiniMax-M2.5-highspeed", "image-01", "image-01-live", + "MiniMax-H3", } var ChannelName = "minimax" diff --git a/relay/channel/task/hailuo_v2/adaptor.go b/relay/channel/task/hailuo_v2/adaptor.go new file mode 100644 index 000000000000..d9ead3cb6d90 --- /dev/null +++ b/relay/channel/task/hailuo_v2/adaptor.go @@ -0,0 +1,366 @@ +package hailuov2 + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + taskdto "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +const ( + ModelName = "MiniMax-H3" + defaultBaseURL = "https://api.minimaxi.com" + legacyBaseURL = "https://api.minimax.chat" + extraImagePrice = 0.04 + freeInputImages = 5 + minVideoDuration = 4 + maxVideoDuration = 15 + maxReferenceInputSeconds = 15 +) + +type TaskAdaptor struct { + taskcommon.BaseBilling + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.apiKey = info.ApiKey + a.baseURL = normalizeBaseURL(info.ChannelBaseUrl) +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *taskdto.TaskError { + var req VideoRequest + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if code, err := validateVideoRequest(&req); err != nil { + return service.TaskErrorWrapperLocal(err, code, http.StatusBadRequest) + } + + req.Model = info.UpstreamModelName + info.Action = constant.TaskActionGenerate + c.Set("task_request", req) + return nil +} + +func validateVideoRequest(req *VideoRequest) (string, error) { + maxAllowedDuration := min(maxVideoDuration, relaycommon.MaxTaskDurationSeconds) + if req.Duration < minVideoDuration || req.Duration > maxAllowedDuration { + return "invalid_duration", fmt.Errorf("duration must be between %d and %d", minVideoDuration, maxAllowedDuration) + } + if req.Resolution != "2K" { + return "invalid_resolution", fmt.Errorf("resolution must be 2K") + } + if req.CallbackURL != nil { + return "unsupported_callback_url", fmt.Errorf("callback_url is not supported") + } + validRatios := map[string]bool{ + "adaptive": true, "21:9": true, "16:9": true, "4:3": true, + "1:1": true, "3:4": true, "9:16": true, + } + ratio := "" + if req.Ratio != nil { + ratio = *req.Ratio + } + if ratio != "" && !validRatios[ratio] { + return "invalid_ratio", fmt.Errorf("unsupported ratio") + } + + hasPrompt := false + hasFrame := false + hasReferenceImage := false + hasReferenceVideo := false + hasReferenceAudio := false + firstFrames := 0 + lastFrames := 0 + referenceImages := 0 + referenceVideos := 0 + referenceAudios := 0 + + for _, item := range req.Content { + role := "" + if item.Role != nil { + role = *item.Role + } + switch item.Type { + case "text": + if item.Text == nil || strings.TrimSpace(*item.Text) == "" { + continue + } + if utf8.RuneCountInString(*item.Text) > 7000 { + return "invalid_content", fmt.Errorf("text content exceeds 7000 characters") + } + hasPrompt = true + case "image_url": + if item.ImageURL == nil || strings.TrimSpace(item.ImageURL.URL) == "" { + return "invalid_content", fmt.Errorf("image_url is required") + } + switch role { + case "", "first_frame": + hasFrame = true + firstFrames++ + case "last_frame": + hasFrame = true + lastFrames++ + case "reference_image": + hasReferenceImage = true + referenceImages++ + default: + return "invalid_content", fmt.Errorf("invalid image role") + } + case "video_url": + if item.VideoURL == nil || strings.TrimSpace(item.VideoURL.URL) == "" || role != "reference_video" { + return "invalid_content", fmt.Errorf("invalid reference video") + } + hasReferenceVideo = true + referenceVideos++ + case "audio_url": + if item.AudioURL == nil || strings.TrimSpace(item.AudioURL.URL) == "" || role != "reference_audio" { + return "invalid_content", fmt.Errorf("invalid reference audio") + } + hasReferenceAudio = true + referenceAudios++ + default: + return "invalid_content", fmt.Errorf("unsupported content type") + } + } + + if !hasPrompt { + return "invalid_content", fmt.Errorf("content must include a non-empty text item") + } + if firstFrames > 1 || lastFrames > 1 || referenceImages > 9 || referenceVideos > 3 || referenceAudios > 3 || referenceImages+referenceVideos+referenceAudios > 12 { + return "invalid_content", fmt.Errorf("content exceeds media count limits") + } + hasReference := hasReferenceImage || hasReferenceVideo || hasReferenceAudio + if hasFrame && hasReference { + return "invalid_content", fmt.Errorf("frame and reference inputs are mutually exclusive") + } + if hasReferenceAudio && !hasReferenceImage && !hasReferenceVideo { + return "invalid_content", fmt.Errorf("reference audio requires a reference image or video") + } + if !hasFrame && !hasReference && (ratio == "" || ratio == "adaptive") { + return "invalid_ratio", fmt.Errorf("text generation requires a non-adaptive ratio") + } + if hasFrame || (hasReference && ratio == "") { + req.Ratio = common.GetPointer("adaptive") + } + return "", nil +} + +func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { + return a.baseURL + "/v2/video_generation", nil +} + +func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { + value, ok := c.Get("task_request") + if !ok { + return nil, fmt.Errorf("request not found in context") + } + req, ok := value.(VideoRequest) + if !ok { + return nil, fmt.Errorf("invalid request type in context") + } + data, err := common.Marshal(req) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (string, []byte, *taskdto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var createResponse CreateResponse + if err := common.Unmarshal(responseBody, &createResponse); err != nil { + return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + } + if createResponse.TaskID == "" { + return "", nil, service.TaskErrorWrapperLocal(fmt.Errorf("upstream task_id is empty"), "invalid_response", http.StatusBadGateway) + } + + video := dto.NewOpenAIVideo() + video.ID = info.PublicTaskID + video.TaskID = info.PublicTaskID + video.CreatedAt = time.Now().Unix() + video.Model = info.OriginModelName + c.JSON(http.StatusOK, video) + return createResponse.TaskID, responseBody, nil +} + +func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok || taskID == "" { + return nil, fmt.Errorf("invalid task_id") + } + requestURL := normalizeBaseURL(baseURL) + "/v2/query/video_generation/" + url.PathEscape(taskID) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var response QueryResponse + if err := common.Unmarshal(respBody, &response); err != nil { + return nil, fmt.Errorf("unmarshal task result failed: %w", err) + } + if response.Task.Status == "" { + var errorResponse ErrorResponse + if err := common.Unmarshal(respBody, &errorResponse); err == nil && errorResponse.Error.Message != "" { + return nil, fmt.Errorf("minimax query failed (%s): %s", errorResponse.Error.HTTPCode, errorResponse.Error.Message) + } + return nil, fmt.Errorf("minimax query returned empty task status") + } + result := &relaycommon.TaskInfo{TaskID: response.Task.ID} + switch response.Task.Status { + case "queued": + result.Status = model.TaskStatusQueued + result.Progress = taskcommon.ProgressQueued + case "running": + result.Status = model.TaskStatusInProgress + result.Progress = taskcommon.ProgressInProgress + case "succeeded": + result.Status = model.TaskStatusSuccess + result.Progress = taskcommon.ProgressComplete + result.Url = response.Task.Content.URL + case "failed", "cancelled", "expired": + result.Status = model.TaskStatusFailure + result.Progress = taskcommon.ProgressComplete + result.Reason = response.Task.Status + if response.Task.Error != nil { + result.Code, _ = strconv.Atoi(response.Task.Error.Code) + result.Reason = response.Task.Error.Message + } + } + return result, nil +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{ModelName} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "minimax-video-v2" +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + video := task.ToOpenAIVideo() + var response QueryResponse + if err := common.Unmarshal(task.Data, &response); err != nil { + return nil, fmt.Errorf("unmarshal task data failed: %w", err) + } + if response.Task.Error != nil { + video.Error = &dto.OpenAIVideoError{ + Code: response.Task.Error.Code, + Message: response.Task.Error.Message, + } + } + return common.Marshal(video) +} + +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + value, ok := c.Get("task_request") + if !ok { + return nil + } + req, ok := value.(VideoRequest) + if !ok || info.PriceData.ModelPrice <= 0 { + return nil + } + + inputImages := 0 + hasReferenceVideo := false + for _, item := range req.Content { + if item.Type == "image_url" { + inputImages++ + } + if item.Type == "video_url" && item.Role != nil && *item.Role == "reference_video" { + hasReferenceVideo = true + } + } + billableSeconds := req.Duration + if hasReferenceVideo { + billableSeconds += maxReferenceInputSeconds + } + estimatedCost := info.PriceData.ModelPrice * float64(billableSeconds) + if inputImages > freeInputImages { + estimatedCost += extraImagePrice * float64(inputImages-freeInputImages) + } + return map[string]float64{"billable_units": estimatedCost / info.PriceData.ModelPrice} +} + +func (a *TaskAdaptor) AdjustBillingOnCompleteChecked(task *model.Task, _ *relaycommon.TaskInfo) (int, *common.QuotaClamp) { + if task == nil || task.PrivateData.BillingContext == nil { + return 0, nil + } + var response QueryResponse + if err := common.Unmarshal(task.Data, &response); err != nil { + logger.LogWarn(context.Background(), fmt.Sprintf("MiniMax H3 task %s keeps its pre-consumed quota because completion usage could not be decoded: %v", task.TaskID, err)) + return 0, nil + } + if response.Task.Usage.TotalSeconds <= 0 { + logger.LogWarn(context.Background(), fmt.Sprintf("MiniMax H3 task %s keeps its pre-consumed quota because completion usage is unavailable", task.TaskID)) + return 0, nil + } + + billing := task.PrivateData.BillingContext + if billing.ModelPrice <= 0 || billing.GroupRatio <= 0 { + return 0, nil + } + cost := billing.ModelPrice * float64(response.Task.Usage.TotalSeconds) + if response.Task.Usage.InputImageCount > freeInputImages { + cost += extraImagePrice * float64(response.Task.Usage.InputImageCount-freeInputImages) + } + return common.QuotaFromFloatChecked(cost * common.QuotaPerUnit * billing.GroupRatio) +} + +func (a *TaskAdaptor) UseCompletionBilling() {} + +func normalizeBaseURL(baseURL string) string { + baseURL = strings.TrimRight(baseURL, "/") + if baseURL == "" || baseURL == legacyBaseURL { + return defaultBaseURL + } + return baseURL +} diff --git a/relay/channel/task/hailuo_v2/adaptor_test.go b/relay/channel/task/hailuo_v2/adaptor_test.go new file mode 100644 index 000000000000..e6a6a017b124 --- /dev/null +++ b/relay/channel/task/hailuo_v2/adaptor_test.go @@ -0,0 +1,207 @@ +package hailuov2 + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newVideoContext(body string) (*gin.Context, *relaycommon.RelayInfo) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/videos", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: legacyBaseURL}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + OriginModelName: ModelName, + } + info.UpstreamModelName = ModelName + return c, info +} + +func TestTaskAdaptorCreatesPublicTaskAndKeepsUpstreamIDPrivate(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + info := &relaycommon.RelayInfo{ + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}, + OriginModelName: ModelName, + } + resp := &http.Response{Body: io.NopCloser(strings.NewReader(`{"task_id":"424010985738629"}`))} + + upstreamID, _, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, info) + + require.Nil(t, taskErr) + require.Equal(t, "424010985738629", upstreamID) + require.Contains(t, recorder.Body.String(), `"id":"task_public"`) + require.NotContains(t, recorder.Body.String(), upstreamID) +} + +func TestTaskAdaptorFetchesAndParsesSucceededTask(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v2/query/video_generation/424010985738629", r.URL.Path) + require.Equal(t, "Bearer secret", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"task":{"id":"424010985738629","status":"succeeded","content":{"url":"https://example.com/video.mp4"},"usage":{"total_seconds":12,"input_seconds":7,"output_seconds":5,"input_image_count":6}}}`)) + })) + defer server.Close() + + adaptor := &TaskAdaptor{} + resp, err := adaptor.FetchTask(server.URL, "secret", map[string]any{"task_id": "424010985738629"}, "") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + result, err := adaptor.ParseTaskResult(body) + + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusSuccess), result.Status) + require.Equal(t, "100%", result.Progress) + require.Equal(t, "https://example.com/video.mp4", result.Url) +} + +func TestTaskAdaptorKeepsTaskPendingOnQueryError(t *testing.T) { + _, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"rate limit","http_code":"429"}}`)) + + require.Error(t, err) +} + +func TestTaskAdaptorReservesWorstCaseReferenceInputCost(t *testing.T) { + c, info := newVideoContext(`{}`) + content := []ContentItem{{Type: "text", Text: common.GetPointer("prompt")}, { + Type: "video_url", VideoURL: &URLValue{URL: "https://example.com/ref.mp4"}, Role: common.GetPointer("reference_video"), + }} + for i := 0; i < 6; i++ { + content = append(content, ContentItem{Type: "image_url", ImageURL: &URLValue{URL: "https://example.com/ref.png"}, Role: common.GetPointer("reference_image")}) + } + c.Set("task_request", VideoRequest{Model: ModelName, Content: content, Resolution: "2K", Duration: 5, Ratio: common.GetPointer("adaptive")}) + info.PriceData.ModelPrice = 0.13 + + ratios := (&TaskAdaptor{}).EstimateBilling(c, info) + + require.InDelta(t, 5+15+0.04/0.13, ratios["billable_units"], 0.000001) +} + +func TestTaskAdaptorSettlesFromUpstreamUsage(t *testing.T) { + task := &model.Task{ + Data: []byte(`{"task":{"usage":{"total_seconds":12,"input_image_count":6}}}`), + PrivateData: model.TaskPrivateData{BillingContext: &model.TaskBillingContext{ + ModelPrice: 0.13, + GroupRatio: 2, + }}, + } + + quota, clamp := (&TaskAdaptor{}).AdjustBillingOnCompleteChecked(task, &relaycommon.TaskInfo{}) + + require.Equal(t, 1600000, quota) + require.Nil(t, clamp) +} + +func TestTaskAdaptorConvertsFailedTaskToOpenAIVideo(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusFailure, + Data: []byte(`{"task":{"status":"failed","error":{"code":"1026","message":"sensitive content"}}}`), + Properties: model.Properties{OriginModelName: ModelName}, + } + + data, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(task) + + require.NoError(t, err) + var response map[string]any + require.NoError(t, common.Unmarshal(data, &response)) + errorBody, ok := response["error"].(map[string]any) + require.True(t, ok) + require.Equal(t, "1026", errorBody["code"]) + require.Equal(t, "sensitive content", errorBody["message"]) +} + +func TestTaskAdaptorBuildsMiniMaxH3CreateRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{ + "model":"MiniMax-H3", + "content":[ + {"type":"text","text":"一个男孩在海边打篮球"}, + {"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"} + ], + "resolution":"2K", + "duration":5, + "ratio":"adaptive" + }` + c, info := newVideoContext(body) + + adaptor := &TaskAdaptor{} + adaptor.Init(info) + require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info)) + requestURL, err := adaptor.BuildRequestURL(info) + require.NoError(t, err) + require.Equal(t, "https://api.minimaxi.com/v2/video_generation", requestURL) + + requestBody, err := adaptor.BuildRequestBody(c, info) + require.NoError(t, err) + data, err := io.ReadAll(requestBody) + require.NoError(t, err) + + var got VideoRequest + require.NoError(t, common.Unmarshal(data, &got)) + require.Equal(t, ModelName, got.Model) + require.Equal(t, "2K", got.Resolution) + require.Equal(t, 5, got.Duration) + require.NotNil(t, got.Ratio) + require.Equal(t, "adaptive", *got.Ratio) + require.Len(t, got.Content, 2) +} + +func TestTaskAdaptorRejectsInvalidMiniMaxH3Content(t *testing.T) { + tests := []struct { + name string + body string + code string + }{ + { + name: "duration is bounded before billing", + body: `{"model":"MiniMax-H3","content":[{"type":"text","text":"prompt"}],"resolution":"2K","duration":16,"ratio":"16:9"}`, + code: "invalid_duration", + }, + { + name: "text generation requires explicit ratio", + body: `{"model":"MiniMax-H3","content":[{"type":"text","text":"prompt"}],"resolution":"2K","duration":5,"ratio":"adaptive"}`, + code: "invalid_ratio", + }, + { + name: "reference audio cannot be used alone", + body: `{"model":"MiniMax-H3","content":[{"type":"text","text":"prompt"},{"type":"audio_url","audio_url":{"url":"https://example.com/ref.mp3"},"role":"reference_audio"}],"resolution":"2K","duration":5,"ratio":"adaptive"}`, + code: "invalid_content", + }, + { + name: "callback cannot expose upstream task id", + body: `{"model":"MiniMax-H3","content":[{"type":"text","text":"prompt"}],"resolution":"2K","duration":5,"ratio":"16:9","callback_url":"https://example.com/callback"}`, + code: "unsupported_callback_url", + }, + { + name: "frame and reference inputs are mutually exclusive", + body: `{"model":"MiniMax-H3","content":[{"type":"text","text":"prompt"},{"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"},{"type":"image_url","image_url":{"url":"https://example.com/ref.png"},"role":"reference_image"}],"resolution":"2K","duration":5,"ratio":"adaptive"}`, + code: "invalid_content", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, info := newVideoContext(test.body) + adaptor := &TaskAdaptor{} + adaptor.Init(info) + + taskErr := adaptor.ValidateRequestAndSetAction(c, info) + + require.NotNil(t, taskErr) + require.Equal(t, test.code, taskErr.Code) + }) + } +} diff --git a/relay/channel/task/hailuo_v2/models.go b/relay/channel/task/hailuo_v2/models.go new file mode 100644 index 000000000000..4e313ecf8e67 --- /dev/null +++ b/relay/channel/task/hailuo_v2/models.go @@ -0,0 +1,66 @@ +package hailuov2 + +type URLValue struct { + URL string `json:"url"` +} + +type ContentItem struct { + Type string `json:"type"` + Text *string `json:"text,omitempty"` + ImageURL *URLValue `json:"image_url,omitempty"` + VideoURL *URLValue `json:"video_url,omitempty"` + AudioURL *URLValue `json:"audio_url,omitempty"` + Role *string `json:"role,omitempty"` +} + +type VideoRequest struct { + Model string `json:"model"` + Content []ContentItem `json:"content"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio *string `json:"ratio,omitempty"` + CallbackURL *string `json:"callback_url,omitempty"` + AIGCWatermark *bool `json:"aigc_watermark,omitempty"` +} + +type CreateResponse struct { + TaskID string `json:"task_id"` +} + +type QueryResponse struct { + Task VideoTask `json:"task"` +} + +type ErrorResponse struct { + Error ErrorDetail `json:"error"` +} + +type ErrorDetail struct { + Type string `json:"type"` + Message string `json:"message"` + HTTPCode string `json:"http_code"` +} + +type VideoTask struct { + ID string `json:"id"` + Status string `json:"status"` + Content VideoTaskContent `json:"content,omitempty"` + Error *VideoTaskError `json:"error,omitempty"` + Usage VideoTaskUsage `json:"usage,omitempty"` +} + +type VideoTaskContent struct { + URL string `json:"url,omitempty"` +} + +type VideoTaskError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type VideoTaskUsage struct { + TotalSeconds int `json:"total_seconds,omitempty"` + InputSeconds int `json:"input_seconds,omitempty"` + OutputSeconds int `json:"output_seconds,omitempty"` + InputImageCount int `json:"input_image_count,omitempty"` +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index e6298dc034f3..b32e95f0d516 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -37,6 +37,7 @@ import ( taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao" taskGemini "github.com/QuantumNous/new-api/relay/channel/task/gemini" "github.com/QuantumNous/new-api/relay/channel/task/hailuo" + hailuov2 "github.com/QuantumNous/new-api/relay/channel/task/hailuo_v2" taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng" "github.com/QuantumNous/new-api/relay/channel/task/kling" tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" @@ -134,7 +135,14 @@ func GetAdaptor(apiType int) channel.Adaptor { } func GetTaskPlatform(c *gin.Context) constant.TaskPlatform { + return GetTaskPlatformForModel(c, "") +} + +func GetTaskPlatformForModel(c *gin.Context, upstreamModel string) constant.TaskPlatform { channelType := c.GetInt("channel_type") + if channelType == constant.ChannelTypeMiniMax && upstreamModel == hailuov2.ModelName { + return constant.TaskPlatformMiniMaxV2 + } if channelType > 0 { return constant.TaskPlatform(strconv.Itoa(channelType)) } @@ -147,6 +155,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { // return &aiproxy.Adaptor{} case constant.TaskPlatformSuno: return &suno.TaskAdaptor{} + case constant.TaskPlatformMiniMaxV2: + return &hailuov2.TaskAdaptor{} } if channelType, err := strconv.ParseInt(string(platform), 10, 64); err == nil { switch channelType { diff --git a/relay/relay_adaptor_minimax_test.go b/relay/relay_adaptor_minimax_test.go new file mode 100644 index 000000000000..cd5575f1adf7 --- /dev/null +++ b/relay/relay_adaptor_minimax_test.go @@ -0,0 +1,25 @@ +package relay + +import ( + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/constant" + hailuov2 "github.com/QuantumNous/new-api/relay/channel/task/hailuo_v2" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestGetTaskAdaptorSelectsMiniMaxVideoV2(t *testing.T) { + adaptor := GetTaskAdaptor(constant.TaskPlatformMiniMaxV2) + + require.IsType(t, &hailuov2.TaskAdaptor{}, adaptor) +} + +func TestGetTaskPlatformForModelSeparatesMiniMaxVideoVersions(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("channel_type", constant.ChannelTypeMiniMax) + + require.Equal(t, string(constant.TaskPlatformMiniMaxV2), string(GetTaskPlatformForModel(c, hailuov2.ModelName))) + require.Equal(t, "35", string(GetTaskPlatformForModel(c, "MiniMax-Hailuo-2.3"))) +} diff --git a/relay/relay_task.go b/relay/relay_task.go index fb384d18937a..9ba09583d9cd 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -23,10 +23,11 @@ import ( ) type TaskSubmitResult struct { - UpstreamTaskID string - TaskData []byte - Platform constant.TaskPlatform - Quota int + UpstreamTaskID string + TaskData []byte + Platform constant.TaskPlatform + Quota int + AdjustBillingOnComplete bool //PerCallPrice types.PriceData } @@ -145,10 +146,19 @@ func ResolveOriginTask(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskErr func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitResult, *dto.TaskError) { info.InitChannelMeta(c) - // 1. 确定 platform → 创建适配器 → 验证请求 - platform := constant.TaskPlatform(c.GetString("platform")) + // 1. 先应用模型映射,再按上游模型选择任务协议。 + // MiniMax V1/V2 共用渠道类型,但异步查询协议不同,因此 platform 必须随任务持久化。 + explicitPlatform := constant.TaskPlatform(c.GetString("platform")) + platform := explicitPlatform + modelName := info.OriginModelName + if modelName != "" { + info.UpstreamModelName = modelName + if err := helper.ModelMappedHelper(c, info, nil); err != nil { + return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest) + } + } if platform == "" { - platform = GetTaskPlatform(c) + platform = GetTaskPlatformForModel(c, info.UpstreamModelName) } adaptor := GetTaskAdaptor(platform) if adaptor == nil { @@ -159,17 +169,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe return nil, taskErr } - // 2. 确定模型名称 - modelName := info.OriginModelName + // 2. 无显式模型的旧任务接口仍按 action 推导模型名。 if modelName == "" { modelName = service.CoverTaskActionToModelName(platform, info.Action) - } - - // 2.5 应用渠道的模型映射(与同步任务对齐) - info.OriginModelName = modelName - info.UpstreamModelName = modelName - if err := helper.ModelMappedHelper(c, info, nil); err != nil { - return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest) + info.OriginModelName = modelName + info.UpstreamModelName = modelName + if err := helper.ModelMappedHelper(c, info, nil); err != nil { + return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest) + } } // 3. 预生成公开 task ID(仅首次) @@ -251,11 +258,13 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } } + _, adjustBillingOnComplete := adaptor.(channel.CompletionBillingAdaptor) return &TaskSubmitResult{ - UpstreamTaskID: upstreamTaskID, - TaskData: taskData, - Platform: platform, - Quota: finalQuota, + UpstreamTaskID: upstreamTaskID, + TaskData: taskData, + Platform: platform, + Quota: finalQuota, + AdjustBillingOnComplete: adjustBillingOnComplete, }, nil } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 53e3f680d01c..dfc659926422 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -751,6 +751,16 @@ type mockAdaptor struct { adjustReturn int } +type checkedMockAdaptor struct { + *mockAdaptor + checkedReturn int + clamp *common.QuotaClamp +} + +func (m *checkedMockAdaptor) AdjustBillingOnCompleteChecked(_ *model.Task, _ *relaycommon.TaskInfo) (int, *common.QuotaClamp) { + return m.checkedReturn, m.clamp +} + func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { return nil, nil @@ -848,3 +858,36 @@ func TestSettle_NonPerCallBilling_AppliesAdaptorAdjustment(t *testing.T) { require.NotNil(t, log) assert.Equal(t, model.LogTypeRefund, log.Type) } + +func TestSettle_CheckedAdaptorAdjustmentTakesPriority(t *testing.T) { + truncate(t) + ctx := context.Background() + + const userID, tokenID, channelID = 33, 33, 33 + const initQuota, preConsumed, actualQuota = 10000, 5000, 3000 + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-checked-adj", 8000) + seedChannel(t, channelID) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + adaptor := &checkedMockAdaptor{ + mockAdaptor: &mockAdaptor{adjustReturn: 4000}, + checkedReturn: actualQuota, + clamp: &common.QuotaClamp{ + Op: "QuotaFromFloat", + Kind: common.QuotaClampOverflow, + Clamped: common.MaxQuota, + }, + } + + settleTaskBillingOnComplete(ctx, adaptor, task, &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}) + + assert.Equal(t, actualQuota, task.Quota) + log := getLastLog(t) + require.NotNil(t, log) + var other map[string]any + require.NoError(t, common.Unmarshal([]byte(log.Other), &other)) + adminInfo, ok := other["admin_info"].(map[string]any) + require.True(t, ok) + require.NotNil(t, adminInfo["quota_saturation"]) +} diff --git a/service/task_polling.go b/service/task_polling.go index 250201ae0525..74659cf403f4 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -34,6 +34,10 @@ type TaskPollingAdaptor interface { AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int } +type checkedTaskBillingAdaptor interface { + AdjustBillingOnCompleteChecked(task *model.Task, taskResult *relaycommon.TaskInfo) (int, *common.QuotaClamp) +} + // GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。 // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor @@ -646,12 +650,19 @@ func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID)) return } - // 1. 优先让 adaptor 决定最终额度 + // 1. 优先使用可审计饱和事件的安全计费接口。 + if checkedAdaptor, ok := adaptor.(checkedTaskBillingAdaptor); ok { + if actualQuota, clamp := checkedAdaptor.AdjustBillingOnCompleteChecked(task, taskResult); actualQuota > 0 { + RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整", clamp) + return + } + } + // 2. 兼容旧适配器的最终额度接口。 if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 { RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整") return } - // 2. 回退到 token 重算 + // 3. 回退到 token 重算 if taskResult.TotalTokens > 0 { RecalculateTaskQuotaByTokens(ctx, task, taskResult.TotalTokens) return diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 829e0794a157..507ad8a1adf9 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -301,6 +301,7 @@ var defaultModelPrice = map[string]float64{ "veo-3.0-fast-generate-001": 0.15, "veo-3.1-generate-preview": 0.4, "veo-3.1-fast-generate-preview": 0.15, + "MiniMax-H3": 0.13, } var defaultAudioRatio = map[string]float64{