diff --git a/constant/task.go b/constant/task.go index 71d6a9fa..e681bac2 100644 --- a/constant/task.go +++ b/constant/task.go @@ -19,6 +19,8 @@ const ( TaskActionKlingOmniVideo = "klingOmniVideo" ) +const TaskModelMiniMaxH3 = "MiniMax-H3" + var SunoModel2Action = map[string]string{ "suno_music": SunoActionMusic, "suno_lyrics": SunoActionLyrics, diff --git a/controller/option.go b/controller/option.go index dd2bee96..a8728562 100644 --- a/controller/option.go +++ b/controller/option.go @@ -15,6 +15,7 @@ import ( "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/MAX-API-Next/MAX-API/setting/system_setting" "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" ) @@ -118,6 +119,76 @@ type OptionUpdateRequest struct { Value any `json:"value"` } +type H3BillingPreviewActual struct { + OutputDurationMs *int64 `json:"output_duration_ms,omitempty"` + InputVideoDurationMs *int64 `json:"input_video_duration_ms,omitempty"` + InputAudioDurationMs *int64 `json:"input_audio_duration_ms,omitempty"` + InputImageCount *int64 `json:"input_image_count,omitempty"` +} + +type H3BillingPreviewRequest struct { + Profile task_billing_setting.H3BillingConfig `json:"profile"` + Resolution string `json:"resolution"` + OutputDurationSeconds int64 `json:"output_duration_seconds"` + InputVideoCount int64 `json:"input_video_count"` + InputAudioCount int64 `json:"input_audio_count"` + InputImageCount int64 `json:"input_image_count"` + GroupRatio float64 `json:"group_ratio"` + Actual *H3BillingPreviewActual `json:"actual,omitempty"` +} + +func buildH3BillingPreviewUsage(actual *H3BillingPreviewActual) *types.TaskUsage { + if actual == nil { + return nil + } + if actual.OutputDurationMs == nil && actual.InputVideoDurationMs == nil && actual.InputAudioDurationMs == nil && actual.InputImageCount == nil { + return nil + } + completeness := types.TaskUsageCompletenessPartial + if actual.OutputDurationMs != nil && actual.InputImageCount != nil { + completeness = types.TaskUsageCompletenessComplete + } + return &types.TaskUsage{ + OutputDurationMs: actual.OutputDurationMs, + InputVideoDurationMs: actual.InputVideoDurationMs, + InputAudioDurationMs: actual.InputAudioDurationMs, + InputImageCount: actual.InputImageCount, + Completeness: completeness, + } +} + +func PreviewH3Billing(c *gin.Context) { + var request H3BillingPreviewRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + usage := buildH3BillingPreviewUsage(request.Actual) + preview, err := task_billing_setting.PreviewH3Billing(request.Profile, task_billing_setting.H3BillingInput{ + Resolution: request.Resolution, + OutputDurationSeconds: request.OutputDurationSeconds, + InputVideoCount: request.InputVideoCount, + InputAudioCount: request.InputAudioCount, + InputImageCount: request.InputImageCount, + }, request.GroupRatio, usage) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "H3 计费预览失败: " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": preview, + }) +} + func UpdateOption(c *gin.Context) { var option OptionUpdateRequest err := common.DecodeJson(c.Request.Body, &option) @@ -294,6 +365,15 @@ func UpdateOption(c *gin.Context) { }) return } + case "task_billing_setting.h3_profiles": + err = task_billing_setting.ValidateH3ProfilesJSON(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "H3 计费配置失败: " + err.Error(), + }) + return + } case "ModelRequestRateLimitGroup": err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) if err != nil { diff --git a/controller/option_test.go b/controller/option_test.go index cf198a49..5941dba3 100644 --- a/controller/option_test.go +++ b/controller/option_test.go @@ -1,12 +1,14 @@ package controller import ( + "bytes" "net/http" "net/http/httptest" "strings" "testing" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -32,3 +34,143 @@ func TestUpdateOptionRejectsUnregisteredKey(t *testing.T) { require.Equal(t, http.StatusBadRequest, recorder.Code) } + +func TestPreviewH3BillingReturnsDraftQuotesWithoutMutatingConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + originalQuotaPerUnit := common.QuotaPerUnit + common.QuotaPerUnit = 1000 + t.Cleanup(func() { common.QuotaPerUnit = originalQuotaPerUnit }) + + originalProfiles := task_billing_setting.GetH3BillingProfilesCopy() + profile, ok := task_billing_setting.GetH3BillingProfileCopy(task_billing_setting.H3BillingProfileKey) + require.True(t, ok) + profile.OutputUnitPrice["768P"] = "0.10" + profile.InputVideoUnitPrice["768P"] = "0.10" + request := H3BillingPreviewRequest{ + Profile: *profile, + Resolution: "768P", + OutputDurationSeconds: 5, + InputVideoCount: 1, + GroupRatio: 1, + Actual: &H3BillingPreviewActual{ + OutputDurationMs: testInt64Pointer(5_000), + InputVideoDurationMs: testInt64Pointer(7_500), + InputImageCount: testInt64Pointer(0), + }, + } + body, err := common.Marshal(request) + require.NoError(t, err) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/option/h3_billing/preview", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + PreviewH3Billing(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Data task_billing_setting.H3BillingPreview `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + require.Equal(t, 2000, response.Data.Reserve.Quota) + require.Equal(t, 1250, response.Data.Final.Quota) + require.Equal(t, 750, *response.Data.RefundQuota) + require.Equal(t, originalProfiles, task_billing_setting.GetH3BillingProfilesCopy()) +} + +func TestPreviewH3BillingRejectsInvalidDraft(t *testing.T) { + gin.SetMode(gin.TestMode) + profile, ok := task_billing_setting.GetH3BillingProfileCopy(task_billing_setting.H3BillingProfileKey) + require.True(t, ok) + profile.InputAudioUnitPrice = "0.01" + body, err := common.Marshal(H3BillingPreviewRequest{ + Profile: *profile, + Resolution: "768P", + OutputDurationSeconds: 5, + GroupRatio: 1, + }) + require.NoError(t, err) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/option/h3_billing/preview", bytes.NewReader(body)) + + PreviewH3Billing(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), `"success":false`) + require.Contains(t, recorder.Body.String(), "input_audio_unit_price must be zero") +} + +func TestPreviewH3BillingDerivesActualUsageCompleteness(t *testing.T) { + gin.SetMode(gin.TestMode) + profile, ok := task_billing_setting.GetH3BillingProfileCopy(task_billing_setting.H3BillingProfileKey) + require.True(t, ok) + + tests := []struct { + name string + actual *H3BillingPreviewActual + wantSuccess bool + wantFinal bool + wantMessagePart string + }{ + { + name: "empty actual is treated as absent", + actual: &H3BillingPreviewActual{}, + wantSuccess: true, + }, + { + name: "missing output duration is partial", + actual: &H3BillingPreviewActual{ + InputImageCount: testInt64Pointer(0), + }, + wantMessagePart: "not automatically billable: partial", + }, + { + name: "missing image count is partial", + actual: &H3BillingPreviewActual{ + OutputDurationMs: testInt64Pointer(5_000), + }, + wantMessagePart: "not automatically billable: partial", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body, err := common.Marshal(H3BillingPreviewRequest{ + Profile: *profile, + Resolution: "768P", + OutputDurationSeconds: 5, + GroupRatio: 1, + Actual: test.actual, + }) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/option/h3_billing/preview", bytes.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + PreviewH3Billing(ctx) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + Data *task_billing_setting.H3BillingPreview `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, test.wantSuccess, response.Success) + if test.wantSuccess { + require.NotNil(t, response.Data) + require.Equal(t, test.wantFinal, response.Data.Final != nil) + return + } + require.Contains(t, response.Message, test.wantMessagePart) + }) + } +} + +func testInt64Pointer(value int64) *int64 { + return &value +} diff --git a/model/option.go b/model/option.go index 80f13b4c..d9f0f2ed 100644 --- a/model/option.go +++ b/model/option.go @@ -394,6 +394,8 @@ func validateOptionUpdate(key string, value string) error { return validateJSONOption[[]map[string]string](value) case "task_billing_setting.rate_cards": return task_billing_setting.ValidateRateCardsJSON(value) + case "task_billing_setting.h3_profiles": + return task_billing_setting.ValidateH3ProfilesJSON(value) default: return nil } @@ -817,9 +819,6 @@ func handleConfigUpdate(key, value string) (bool, error) { InvalidatePricingCache() ratio_setting.InvalidateExposedDataCache() } else if configName == "task_billing_setting" { - if configKey == "rate_cards" { - _ = task_billing_setting.ValidateRateCardsJSON(value) - } InvalidatePricingCache() } else if configName == "theme" { system_setting.UpdateAndSyncTheme() diff --git a/model/option_test.go b/model/option_test.go index e8a11944..fdde5a6c 100644 --- a/model/option_test.go +++ b/model/option_test.go @@ -292,6 +292,7 @@ func TestValidateOptionUpdateRejectsNullRWMapConfigs(t *testing.T) { "billing_setting.billing_mode", "billing_setting.billing_expr", "task_billing_setting.rate_cards", + "task_billing_setting.h3_profiles", } { t.Run(key, func(t *testing.T) { require.Error(t, validateOptionUpdate(key, "null")) @@ -299,6 +300,17 @@ func TestValidateOptionUpdateRejectsNullRWMapConfigs(t *testing.T) { } } +func TestValidateOptionUpdateRejectsSemanticallyInvalidTaskBillingConfigs(t *testing.T) { + for key, value := range map[string]string{ + "task_billing_setting.rate_cards": `{ "invalid": { "billing_type": "unsupported" } }`, + "task_billing_setting.h3_profiles": `{}`, + } { + t.Run(key, func(t *testing.T) { + require.Error(t, validateOptionUpdate(key, value)) + }) + } +} + func TestNormalizeDataExportIntervalRejectsUnsafeValues(t *testing.T) { for _, value := range []string{"0", "-1", "1441", "not-a-number"} { t.Run(value, func(t *testing.T) { diff --git a/model/pricing.go b/model/pricing.go index 98265396..628b1424 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -452,7 +452,7 @@ func updatePricing() { } func newTaskRateCardPricing(ruleKey string, card *task_billing_setting.RateCard) *TaskRateCardPricing { - if card == nil { + if card == nil || card.BillingType != "" { return nil } rows := make([]TaskRateCardPricingRow, len(card.Rows)) diff --git a/model/pricing_test.go b/model/pricing_test.go index 05dae0b8..be8f5212 100644 --- a/model/pricing_test.go +++ b/model/pricing_test.go @@ -5,6 +5,7 @@ import ( "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" "github.com/stretchr/testify/require" ) @@ -40,3 +41,12 @@ func TestGetPricingEndpointTypesForAdvancedCustomAbilityUsesConfiguredRoutes(t * constant.EndpointTypeOpenAIResponse, }, endpointTypes) } + +func TestNewTaskRateCardPricingOmitsStructuredBillingCards(t *testing.T) { + pricing := newTaskRateCardPricing("minimax/minimax-h3", &task_billing_setting.RateCard{ + Vendor: "minimax", + BillingType: task_billing_setting.MinimaxBillingType, + }) + + require.Nil(t, pricing) +} diff --git a/model/task.go b/model/task.go index 949977b8..dafdac7f 100644 --- a/model/task.go +++ b/model/task.go @@ -148,6 +148,7 @@ type TaskBillingContext struct { ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"` + TaskBillingPlan *types.TaskBillingPlan `json:"task_billing_plan,omitempty"` OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 DeltaSettlementDisabled *bool `json:"delta_settlement_disabled,omitempty"` // 渠道关闭完成态差额结算时按提交快照跳过 diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 95c6a5bf..9345b986 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -81,3 +81,16 @@ type TaskAdaptor interface { type OpenAIVideoConverter interface { ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) } + +// TaskUsageProvider is an optional provider-specific usage extractor. It does +// not change the required TaskAdaptor contract, so existing adaptors keep +// their current behavior until they opt in. +type TaskUsageProvider interface { + ExtractTaskUsage(respBody []byte) (*types.TaskUsage, error) +} + +// TaskBillingPlanProvider is an optional provider-specific request normalizer. +// It only produces a bounded pricing snapshot; it has no funding authority. +type TaskBillingPlanProvider interface { + BuildTaskBillingPlan(c *gin.Context, info *relaycommon.RelayInfo) (*types.TaskBillingPlan, error) +} diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index c0dbebf8..98ed90f9 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "strings" "time" @@ -20,6 +21,8 @@ import ( taskcommon "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" + "github.com/MAX-API-Next/MAX-API/types" ) // https://platform.minimaxi.com/docs/api-reference/video-generation-intro @@ -37,11 +40,33 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { } func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { - return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) + if taskErr := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate); taskErr != nil { + return taskErr + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + if isUnsupportedH3Model(h3RequestModel(&req, info)) { + return service.TaskErrorWrapperLocal(fmt.Errorf("%s is not supported by this adaptor", H3MaxModel), "unsupported_model", http.StatusBadRequest) + } + if h3RequestUsesModel(&req, info) { + if _, err := buildH3Request(&req, info); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + } + } + return nil } func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { - fallback := fmt.Sprintf("%s%s", a.baseURL, TextToVideoEndpoint) + if isUnsupportedH3Model(h3RequestModel(nil, info)) { + return "", fmt.Errorf("%s is not supported by this adaptor", H3MaxModel) + } + endpoint := TextToVideoEndpoint + if isH3Model(h3RequestModel(nil, info)) { + endpoint = H3TextToVideoEndpoint + } + fallback := fmt.Sprintf("%s%s", strings.TrimRight(a.baseURL, "/"), endpoint) return taskcommon.BuildTaskSubmitURL(info, fallback), nil } @@ -61,8 +86,19 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayIn if !ok { return nil, fmt.Errorf("invalid request type in context") } + if isUnsupportedH3Model(h3RequestModel(&req, info)) { + return nil, fmt.Errorf("%s is not supported by this adaptor", H3MaxModel) + } - body, err := a.convertToRequestPayload(&req, info) + var ( + body any + err error + ) + if h3RequestUsesModel(&req, info) { + body, err = buildH3Request(&req, info) + } else { + body, err = a.convertToRequestPayload(&req, info) + } if err != nil { return nil, errors.Wrap(err, "convert request payload failed") } @@ -91,6 +127,45 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return taskID, responseBody, configuredErr } + if isH3Model(h3RequestModel(nil, info)) { + var h3Resp struct { + TaskID string `json:"task_id"` + Error *H3APIError `json:"error,omitempty"` + BaseResp *BaseResp `json:"base_resp,omitempty"` + } + if err := common.Unmarshal(responseBody, &h3Resp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + if h3Resp.BaseResp != nil && h3Resp.BaseResp.StatusCode != StatusSuccess { + message := strings.TrimSpace(h3Resp.BaseResp.StatusMsg) + if message == "" { + message = "H3 submit failed" + } + taskErr = service.TaskErrorWrapper(fmt.Errorf("H3 api error: %s", message), strconv.Itoa(h3Resp.BaseResp.StatusCode), http.StatusBadRequest) + return + } + if h3Resp.Error != nil { + message := strings.TrimSpace(h3Resp.Error.Message) + if message == "" { + message = "H3 submit failed" + } + taskErr = service.TaskErrorWrapper(fmt.Errorf("H3 api error: %s", message), strconv.Itoa(h3APIErrorCode(h3Resp.Error)), http.StatusBadRequest) + return + } + if strings.TrimSpace(h3Resp.TaskID) == "" { + taskErr = service.TaskErrorWrapperLocal(errors.New("H3 submit response missing task id"), "missing_upstream_task_id", http.StatusBadGateway) + return + } + ov := dto.NewOpenAIVideo() + ov.ID = info.PublicTaskID + ov.TaskID = info.PublicTaskID + ov.CreatedAt = time.Now().Unix() + ov.Model = info.OriginModelName + c.JSON(http.StatusOK, ov) + return h3Resp.TaskID, responseBody, nil + } + var hResp VideoResponse if err := common.Unmarshal(responseBody, &hResp); err != nil { taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) @@ -116,13 +191,54 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return hResp.TaskID, responseBody, nil } +func (a *TaskAdaptor) BuildTaskBillingPlan(c *gin.Context, info *relaycommon.RelayInfo) (*types.TaskBillingPlan, error) { + if !isH3Model(h3RequestModel(nil, info)) { + return nil, nil + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil, err + } + h3Request, err := buildH3Request(&req, info) + if err != nil { + return nil, err + } + input := task_billing_setting.H3BillingInput{ + Resolution: h3Request.Resolution, + OutputDurationSeconds: int64(h3Request.Duration), + } + for _, item := range h3Request.Content { + switch item.Type { + case "image_url": + input.InputImageCount++ + case "video_url": + input.InputVideoCount++ + case "audio_url": + input.InputAudioCount++ + } + } + groupRatio := 1.0 + if info != nil { + groupRatio = info.PriceData.GroupRatioInfo.GroupRatio + } + models := []string{h3RequestModel(nil, info)} + if info != nil { + models = append(models, info.OriginModelName, info.UpstreamModelName) + } + return task_billing_setting.BuildH3BillingPlanForModels(input, groupRatio, models...) +} + func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) { taskID, ok := body["task_id"].(string) if !ok { return nil, fmt.Errorf("invalid task_id") } + modelName, _ := body["model"].(string) uri := fmt.Sprintf("%s%s?task_id=%s", baseUrl, QueryTaskEndpoint, taskID) + if isH3Model(modelName) { + uri = fmt.Sprintf("%s%s/%s", strings.TrimRight(baseUrl, "/"), H3QueryTaskEndpoint, url.PathEscape(taskID)) + } uri = taskcommon.BuildTaskQueryURL(baseUrl, body, uri) req, err := http.NewRequest(http.MethodGet, uri, nil) @@ -188,6 +304,10 @@ func (a *TaskAdaptor) parseResolutionFromSize(size string, modelConfig ModelConf } func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + if result, handled, err := parseH3TaskResult(respBody); handled { + return result, err + } + resTask := QueryTaskResponse{} if err := common.Unmarshal(respBody, &resTask); err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") @@ -237,7 +357,57 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e return &taskResult, nil } +func (a *TaskAdaptor) ExtractTaskUsage(respBody []byte) (*types.TaskUsage, error) { + response, handled, err := parseH3Response(respBody) + if err != nil { + return nil, err + } + if !handled || response.Task == nil { + return nil, nil + } + return normalizeH3Usage(response.Task.Usage), nil +} + func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + var h3Resp H3QueryResponse + if err := common.Unmarshal(originTask.Data, &h3Resp); err == nil && (h3Resp.Task != nil || h3Resp.Error != nil) { + openAIVideo := originTask.ToOpenAIVideo() + if h3Resp.Error != nil { + openAIVideo.Status = dto.VideoStatusFailed + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: h3Resp.Error.Message, + Code: strconv.Itoa(h3APIErrorCode(h3Resp.Error)), + } + } else { + switch strings.ToLower(strings.TrimSpace(h3Resp.Task.Status)) { + case "queued": + openAIVideo.Status = dto.VideoStatusQueued + case "running": + openAIVideo.Status = dto.VideoStatusInProgress + case "succeeded": + if h3Resp.Task.Content != nil && strings.TrimSpace(h3Resp.Task.Content.URL) != "" { + openAIVideo.Status = dto.VideoStatusCompleted + } else { + openAIVideo.Status = dto.VideoStatusInProgress + } + case "failed", "cancelled": + openAIVideo.Status = dto.VideoStatusFailed + openAIVideo.Error = &dto.OpenAIVideoError{ + Message: h3Resp.Task.ErrorMessage(), + Code: strconv.Itoa(h3CodeNumber(h3Resp.Task.ErrorCode())), + } + } + if h3Resp.Task.Content != nil && strings.TrimSpace(h3Resp.Task.Content.URL) != "" { + openAIVideo.SetMetadata("url", strings.TrimSpace(h3Resp.Task.Content.URL)) + } + } + jsonData, err := common.Marshal(openAIVideo) + if err != nil { + return nil, errors.Wrap(err, "marshal openai video failed") + } + return jsonData, nil + } + var hailuoResp QueryTaskResponse if err := common.Unmarshal(originTask.Data, &hailuoResp); err != nil { return nil, errors.Wrap(err, "unmarshal hailuo task data failed") diff --git a/relay/channel/task/hailuo/adaptor_test.go b/relay/channel/task/hailuo/adaptor_test.go index 1bdb51ed..10edd26f 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -1,16 +1,617 @@ package hailuo import ( + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/relay/helper" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +func newH3TestInfo() *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + OriginModelName: H3Model, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeMiniMax, + ChannelBaseUrl: H3TestBaseURL, + UpstreamModelName: H3Model, + }, + } +} + +const H3TestBaseURL = "https://api.minimax.io" + +func TestBuildH3RequestUsesOfficialV2ContentShape(t *testing.T) { + duration := 5 + ratio := "16:9" + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "A cinematic ocean sunrise", + Duration: &duration, + Resolution: Resolution2K, + Ratio: &ratio, + Content: []map[string]any{ + {"type": "text", "text": "A cinematic ocean sunrise"}, + {"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/frame.png"}}, + }, + }) + + body, err := (&TaskAdaptor{}).BuildRequestBody(c, newH3TestInfo()) + require.NoError(t, err) + data, err := io.ReadAll(body) + require.NoError(t, err) + + var request H3VideoRequest + require.NoError(t, common.Unmarshal(data, &request)) + require.Equal(t, H3Model, request.Model) + require.Equal(t, Resolution2K, request.Resolution) + require.Equal(t, 5, request.Duration) + require.Equal(t, "adaptive", request.Ratio) + require.Len(t, request.Content, 2) + require.Equal(t, "https://example.com/frame.png", request.Content[1].ImageURL.URL) +} + +func TestBuildH3RequestAndBillingPlanUseDurationSeconds(t *testing.T) { + durationSeconds := 10 + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "A cinematic ocean sunrise", + DurationSeconds: &durationSeconds, + Content: []map[string]any{{"type": "text", "text": "A cinematic ocean sunrise"}}, + }) + info := newH3TestInfo() + info.PriceData.GroupRatioInfo.GroupRatio = 1 + + body, err := (&TaskAdaptor{}).BuildRequestBody(c, info) + require.NoError(t, err) + data, err := io.ReadAll(body) + require.NoError(t, err) + var request H3VideoRequest + require.NoError(t, common.Unmarshal(data, &request)) + require.Equal(t, 10, request.Duration) + + plan, err := (&TaskAdaptor{}).BuildTaskBillingPlan(c, info) + require.NoError(t, err) + require.EqualValues(t, 10, plan.RequestedOutputDurationSeconds) +} + +func TestH3UsesEffectiveUpstreamModelAfterMapping(t *testing.T) { + info := newH3TestInfo() + info.OriginModelName = H3Model + info.ChannelMeta.UpstreamModelName = "MiniMax-Hailuo-2.3" + req := &relaycommon.TaskSubmitReq{Model: H3Model} + + require.False(t, h3RequestUsesModel(req, info)) + require.Equal(t, "MiniMax-Hailuo-2.3", h3RequestModel(req, info)) + + info.ChannelMeta.UpstreamModelName = H3Model + info.OriginModelName = "h3-alias" + require.True(t, h3RequestUsesModel(req, info)) + require.Equal(t, H3Model, h3RequestModel(req, info)) +} + +func TestValidateH3RequestAcceptsTextOnlyInsideContent(t *testing.T) { + gin.SetMode(gin.TestMode) + request := httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model":"MiniMax-H3", + "content":[{"type":"text","text":"Create a short film"}] + }`)) + request.Header.Set("Content-Type", "application/json") + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = request + + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, newH3TestInfo()) + require.Nil(t, taskErr) +} + +func TestValidateH3RequestRejectsContentOnlyAfterMappingToLegacyModel(t *testing.T) { + gin.SetMode(gin.TestMode) + request := httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model":"MiniMax-H3", + "content":[{"type":"text","text":"Create a short film"}] + }`)) + request.Header.Set("Content-Type", "application/json") + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = request + c.Set("model_mapping", `{"MiniMax-H3":"MiniMax-Hailuo-2.3"}`) + info := newH3TestInfo() + + require.NoError(t, helper.ModelMappedHelper(c, info, nil)) + require.Equal(t, "MiniMax-Hailuo-2.3", info.UpstreamModelName) + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + require.NotNil(t, taskErr) + require.Contains(t, taskErr.Message, "prompt is required") +} + +func TestBuildH3RequestMapsReferenceVideoAndAudio(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Match the reference performance", + Metadata: map[string]any{ + "reference_video": []any{"https://example.com/reference.mp4"}, + "reference_audio": []any{"https://example.com/reference.mp3"}, + }, + }) + + body, err := (&TaskAdaptor{}).BuildRequestBody(c, newH3TestInfo()) + require.NoError(t, err) + data, err := io.ReadAll(body) + require.NoError(t, err) + + var request H3VideoRequest + require.NoError(t, common.Unmarshal(data, &request)) + require.Equal(t, "adaptive", request.Ratio) + require.Len(t, request.Content, 3) + require.Equal(t, "reference_video", request.Content[1].Role) + require.Equal(t, "reference_audio", request.Content[2].Role) +} + +func TestBuildH3RequestRejectsReferenceImagesMixedWithFrameImages(t *testing.T) { + tests := []struct { + name string + request relaycommon.TaskSubmitReq + }{ + { + name: "top-level reference images with first frame", + request: relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Match the reference performance", + ReferenceImages: []string{"https://example.com/reference.png"}, + Metadata: map[string]any{"first_frame_image": "https://example.com/first.png"}, + }, + }, + { + name: "metadata reference images with last frame", + request: relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Match the reference performance", + Metadata: map[string]any{ + "first_frame_image": "https://example.com/first.png", + "last_frame_image": "https://example.com/last.png", + "reference_images": []any{"https://example.com/reference.png"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildH3Request(&tt.request, newH3TestInfo()) + require.Error(t, err) + require.Contains(t, err.Error(), "H3 frame images and reference media cannot be mixed") + }) + } +} + +func TestBuildH3RequestNormalizesCallbackURL(t *testing.T) { + direct := " https://example.com/direct-callback " + directBlank := " " + tests := []struct { + name string + callbackURL *string + metadata map[string]any + wantURL string + wantNil bool + wantErr string + }{ + { + name: "direct value is trimmed", + callbackURL: &direct, + metadata: map[string]any{"callback_url": 42}, + wantURL: "https://example.com/direct-callback", + }, + { + name: "direct blank remains authoritative and is omitted", + callbackURL: &directBlank, + metadata: map[string]any{"callback_url": "https://example.com/metadata-callback"}, + wantNil: true, + }, + { + name: "metadata value is trimmed", + metadata: map[string]any{"callback_url": " https://example.com/metadata-callback "}, + wantURL: "https://example.com/metadata-callback", + }, + { + name: "metadata blank is omitted", + metadata: map[string]any{"callback_url": " "}, + wantNil: true, + }, + { + name: "metadata null is omitted", + metadata: map[string]any{"callback_url": nil}, + wantNil: true, + }, + { + name: "metadata non-string is rejected", + metadata: map[string]any{"callback_url": 42}, + wantErr: "callback_url must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request, err := buildH3Request(&relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Create a video", + CallbackURL: tt.callbackURL, + Metadata: tt.metadata, + }, newH3TestInfo()) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantNil { + require.Nil(t, request.CallbackURL) + return + } + require.NotNil(t, request.CallbackURL) + require.Equal(t, tt.wantURL, *request.CallbackURL) + }) + } +} + +func TestBuildH3RequestRejectsNonStringMetadataOptions(t *testing.T) { + tests := []struct { + name string + metadata map[string]any + wantErr string + }{ + { + name: "resolution", + metadata: map[string]any{"resolution": 1080}, + wantErr: "resolution must be a string", + }, + { + name: "ratio", + metadata: map[string]any{"ratio": 16}, + wantErr: "ratio must be a string", + }, + { + name: "resolution null", + metadata: map[string]any{"resolution": nil}, + wantErr: "resolution must be a string", + }, + { + name: "ratio null", + metadata: map[string]any{"ratio": nil}, + wantErr: "ratio must be a string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildH3Request(&relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Create a video", + Metadata: tt.metadata, + }, newH3TestInfo()) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestBuildH3BillingPlanCapturesRequestFactsWithoutChargingAudio(t *testing.T) { + originalQuotaPerUnit := common.QuotaPerUnit + common.QuotaPerUnit = 1000 + t.Cleanup(func() { common.QuotaPerUnit = originalQuotaPerUnit }) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Match the references", + Content: []map[string]any{ + {"type": "text", "text": "Match the references"}, + {"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": "https://example.com/1.png"}}, + {"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/1.mp4"}}, + {"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": "https://example.com/2.mp4"}}, + {"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": "https://example.com/1.mp3"}}, + }, + }) + info := newH3TestInfo() + info.PriceData.GroupRatioInfo.GroupRatio = 1 + + plan, err := (&TaskAdaptor{}).BuildTaskBillingPlan(c, info) + require.NoError(t, err) + require.EqualValues(t, 2, plan.InputVideoCount) + require.EqualValues(t, 1, plan.InputAudioCount) + require.EqualValues(t, 1, plan.InputImageCount) + require.NotEmpty(t, plan.ConfigHash) + // The two videos share one aggregate 15-second reserve; audio is free. + require.EqualValues(t, 1600, plan.ReserveQuota) +} + +func TestH3BuildRequestRejectsUnsupportedResolution(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: H3Model, + Prompt: "Create a video", + Resolution: "1080P", + }) + + _, err := (&TaskAdaptor{}).BuildRequestBody(c, newH3TestInfo()) + require.Error(t, err) + require.Contains(t, err.Error(), "768P or 2K") +} + +func TestH3MaxIsRejectedInsteadOfUsingLegacyV1Protocol(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: H3MaxModel, + Prompt: "Create a video", + }) + info := newH3TestInfo() + info.OriginModelName = H3MaxModel + info.ChannelMeta.UpstreamModelName = H3MaxModel + + _, err := (&TaskAdaptor{}).BuildRequestBody(c, info) + require.Error(t, err) + require.Contains(t, err.Error(), H3MaxModel) + + _, err = (&TaskAdaptor{baseURL: H3TestBaseURL}).BuildRequestURL(info) + require.Error(t, err) + require.Contains(t, err.Error(), H3MaxModel) +} + +func TestH3QueryUsesV2PathAndEscapesTaskID(t *testing.T) { + service.InitHttpClient() + requestPath := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath <- r.URL.EscapedPath() + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + resp, err := (&TaskAdaptor{}).FetchTask(server.URL, "test-key", map[string]any{ + "model": H3Model, + "task_id": "task/with-special-id", + }, "") + require.NoError(t, err) + require.NotNil(t, resp) + defer resp.Body.Close() + + select { + case path := <-requestPath: + require.Equal(t, "/v2/query/video_generation/task%2Fwith-special-id", path) + case <-time.After(time.Second): + t.Fatal("timed out waiting for the H3 query request") + } +} + +func TestH3DoResponseAcceptsBareTaskID(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + info := newH3TestInfo() + info.PublicTaskID = "task_public" + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"task_id":"upstream-task"}`)), + } + + taskID, taskData, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, info) + require.Nil(t, taskErr) + require.Equal(t, "upstream-task", taskID) + require.JSONEq(t, `{"task_id":"upstream-task"}`, string(taskData)) + require.Equal(t, http.StatusOK, recorder.Code) + + var video dto.OpenAIVideo + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &video)) + require.Equal(t, "task_public", video.ID) + require.Equal(t, dto.VideoStatusQueued, video.Status) +} + +func TestH3DoResponseRejectsProviderError(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + info := newH3TestInfo() + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"invalid content","http_code":400}}`)), + } + + taskID, taskData, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, info) + require.Empty(t, taskID) + require.Empty(t, taskData) + require.NotNil(t, taskErr) + require.Equal(t, "400", taskErr.Code) + require.Contains(t, taskErr.Error.Error(), "invalid content") +} + +func TestH3DoResponseRejectsBaseRespError(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + info := newH3TestInfo() + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"base_resp":{"status_code":1004,"status_msg":"invalid request"}}`)), + } + + _, _, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, info) + require.NotNil(t, taskErr) + require.Equal(t, "1004", taskErr.Code) + require.Contains(t, taskErr.Error.Error(), "invalid request") +} + +func TestParseH3TaskResultCarriesVideoAndAudioUsageSeparately(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task": { + "id": "task-1", + "model": "MiniMax-H3", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/result.mp4"}, + "usage": { + "total_seconds": 5, + "input_seconds": 0, + "output_seconds": 5, + "input_image_count": 1, + "input_audio_seconds": 6 + } + } + }`)) + require.NoError(t, err) + require.Equal(t, "SUCCESS", result.Status) + require.Equal(t, "https://cdn.example.com/result.mp4", result.Url) + require.NotNil(t, result.Usage) + require.Equal(t, types.TaskUsageCompletenessComplete, result.Usage.Completeness) + require.Equal(t, int64(5000), *result.Usage.OutputDurationMs) + require.Equal(t, int64(0), *result.Usage.InputVideoDurationMs) + require.Equal(t, int64(6000), *result.Usage.InputAudioDurationMs) + require.Equal(t, int64(1), *result.Usage.InputImageCount) + require.Equal(t, types.TaskUsageSourceProviderResponse, result.Usage.Source) +} + +func TestParseH3UsagePreservesExplicitZeroAndMissingFields(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task": { + "id": "task-2", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/result.mp4"}, + "usage": { + "total_seconds": 5, + "input_seconds": 0, + "output_seconds": 5, + "input_image_count": 0 + } + } + }`)) + require.NoError(t, err) + require.NotNil(t, result.Usage.InputVideoDurationMs) + require.Equal(t, int64(0), *result.Usage.InputVideoDurationMs) + require.Nil(t, result.Usage.InputAudioDurationMs) + + missing, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task": {"id":"task-3","status":"succeeded","content":{"url":"https://cdn.example.com/result.mp4"}} + }`)) + require.NoError(t, err) + require.Equal(t, types.TaskUsageCompletenessMissing, missing.Usage.Completeness) +} + +func TestParseH3UsageMarksInvalidProviderValues(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task": { + "id": "task-4", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/result.mp4"}, + "usage": { + "total_seconds": 5, + "input_seconds": 0, + "output_seconds": "not-a-number", + "input_image_count": "not-a-number" + } + } + }`)) + require.NoError(t, err) + require.Equal(t, types.TaskUsageCompletenessInvalid, result.Usage.Completeness) + require.Nil(t, result.Usage.InputImageCount) +} + +func TestParseH3UsagePreservesFractionalVideoAndAudioSeconds(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task": { + "id": "task-fractional", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/result.mp4"}, + "usage": { + "total_seconds": 12.5, + "input_seconds": 7.5, + "output_seconds": 5, + "input_image_count": 1, + "input_audio_seconds": 2.25 + } + } + }`)) + require.NoError(t, err) + require.Equal(t, types.TaskUsageCompletenessComplete, result.Usage.Completeness) + require.Equal(t, int64(7_500), *result.Usage.InputVideoDurationMs) + require.Equal(t, int64(2_250), *result.Usage.InputAudioDurationMs) + require.Equal(t, int64(5_000), *result.Usage.OutputDurationMs) +} + +func TestConvertH3TaskToOpenAIVideo(t *testing.T) { + tests := []struct { + name string + status model.TaskStatus + data string + wantStatus string + wantURL string + }{ + { + name: "success", + status: model.TaskStatusSuccess, + data: `{"task":{"id":"upstream-task","model":"MiniMax-H3","status":"succeeded","content":{"url":"https://cdn.example.com/video.mp4"}}}`, + wantStatus: dto.VideoStatusCompleted, + wantURL: "https://cdn.example.com/video.mp4", + }, + { + name: "success without content", + status: model.TaskStatusSuccess, + data: `{"task":{"id":"upstream-task","model":"MiniMax-H3","status":"succeeded"}}`, + wantStatus: dto.VideoStatusInProgress, + }, + { + name: "success with blank content url", + status: model.TaskStatusSuccess, + data: `{"task":{"id":"upstream-task","model":"MiniMax-H3","status":"succeeded","content":{"url":" "}}}`, + wantStatus: dto.VideoStatusInProgress, + }, + { + name: "failure", + status: model.TaskStatusFailure, + data: `{"task":{"id":"upstream-task","model":"MiniMax-H3","status":"failed","error":{"code":3001,"message":"generation failed"}}}`, + wantStatus: dto.VideoStatusFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(&model.Task{ + TaskID: "task_public", + Status: tt.status, + Properties: model.Properties{OriginModelName: H3Model}, + Data: []byte(tt.data), + }) + require.NoError(t, err) + + var video dto.OpenAIVideo + require.NoError(t, common.Unmarshal(body, &video)) + require.Equal(t, tt.wantStatus, video.Status) + if tt.wantURL != "" { + require.Equal(t, tt.wantURL, video.Metadata["url"]) + } + if tt.name == "failure" { + require.NotNil(t, video.Error) + require.Equal(t, "generation failed", video.Error.Message) + require.Equal(t, "3001", video.Error.Code) + } + }) + } +} + +func TestH3TransientQueryErrorKeepsPollingRetryable(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "type":"error", + "error":{"type":"rate_limit_error","message":"retry later","http_code":"429"} + }`)) + require.Error(t, err) + require.Nil(t, result) +} + func TestParseTaskResultWaitsForRetrievableVideoURL(t *testing.T) { service.InitHttpClient() diff --git a/relay/channel/task/hailuo/constants.go b/relay/channel/task/hailuo/constants.go index 5e540863..6216806e 100644 --- a/relay/channel/task/hailuo/constants.go +++ b/relay/channel/task/hailuo/constants.go @@ -1,10 +1,16 @@ package hailuo +import ( + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" +) + const ( ChannelName = "hailuo-video" ) var ModelList = []string{ + H3Model, "MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast", "MiniMax-Hailuo-02", @@ -17,8 +23,12 @@ var ModelList = []string{ } const ( - TextToVideoEndpoint = "/v1/video_generation" - QueryTaskEndpoint = "/v1/query/video_generation" + TextToVideoEndpoint = "/v1/video_generation" + QueryTaskEndpoint = "/v1/query/video_generation" + H3Model = constant.TaskModelMiniMaxH3 + H3MaxModel = "MiniMax-H3-Max" + H3TextToVideoEndpoint = "/v2/video_generation" + H3QueryTaskEndpoint = "/v2/query/video_generation" ) const ( @@ -44,9 +54,17 @@ const ( Resolution720P = "720P" Resolution768P = "768P" Resolution1080P = "1080P" + Resolution2K = "2K" ) const ( - DefaultDuration = 6 - DefaultResolution = Resolution720P + DefaultDuration = 6 + DefaultResolution = Resolution720P + H3DefaultDuration = 5 + H3MinDuration = task_billing_setting.H3MinOutputDurationSeconds + H3MaxDuration = task_billing_setting.H3MaxOutputDurationSeconds + H3MaxInputVideoSeconds = task_billing_setting.H3MaxInputMediaDurationSeconds + H3MaxImages = task_billing_setting.H3MaxInputImageCount + H3MaxVideos = task_billing_setting.H3MaxInputVideoCount + H3MaxAudios = task_billing_setting.H3MaxInputAudioCount ) diff --git a/relay/channel/task/hailuo/h3.go b/relay/channel/task/hailuo/h3.go new file mode 100644 index 00000000..84d133d8 --- /dev/null +++ b/relay/channel/task/hailuo/h3.go @@ -0,0 +1,746 @@ +package hailuo + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/model" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/shopspring/decimal" +) + +var h3Ratios = map[string]struct{}{ + "adaptive": {}, + "21:9": {}, + "16:9": {}, + "4:3": {}, + "1:1": {}, + "3:4": {}, + "9:16": {}, +} + +func isH3Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), constant.TaskModelMiniMaxH3) +} + +func isUnsupportedH3Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), H3MaxModel) +} + +func h3RequestModel(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) string { + if info != nil { + if info.ChannelMeta != nil { + if model := strings.TrimSpace(info.ChannelMeta.UpstreamModelName); model != "" { + return model + } + } + if model := strings.TrimSpace(info.OriginModelName); model != "" { + return model + } + } + if req != nil { + return req.Model + } + return "" +} + +func h3RequestUsesModel(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) bool { + if info != nil { + // Once channel model mapping has run, the effective upstream model is + // authoritative. This also prevents mapping H3 to a legacy model while + // still selecting the v2 protocol only because the client sent H3. + if info.ChannelMeta != nil && strings.TrimSpace(info.ChannelMeta.UpstreamModelName) != "" { + return isH3Model(info.ChannelMeta.UpstreamModelName) + } + if isH3Model(info.OriginModelName) { + return true + } + } + if req != nil && isH3Model(req.Model) { + return true + } + return false +} + +func decodeH3ContentItem(raw map[string]any) (H3ContentItem, error) { + data, err := common.Marshal(raw) + if err != nil { + return H3ContentItem{}, fmt.Errorf("marshal H3 content item: %w", err) + } + var item H3ContentItem + if err := common.Unmarshal(data, &item); err != nil { + return H3ContentItem{}, fmt.Errorf("decode H3 content item: %w", err) + } + item.Type = strings.ToLower(strings.TrimSpace(item.Type)) + item.Role = strings.ToLower(strings.TrimSpace(item.Role)) + if item.Type == "image_url" && item.Role == "" { + item.Role = "first_frame" + } + return item, nil +} + +func validateH3Content(items []H3ContentItem) error { + textCount := 0 + firstFrames := 0 + lastFrames := 0 + referenceImages := 0 + referenceVideos := 0 + referenceAudios := 0 + + for i := range items { + item := &items[i] + switch item.Type { + case "text": + if strings.TrimSpace(item.Text) == "" { + return fmt.Errorf("H3 content item %d has empty text", i) + } + textCount++ + case "image_url": + if item.ImageURL == nil || strings.TrimSpace(item.ImageURL.URL) == "" { + return fmt.Errorf("H3 content item %d has invalid image_url", i) + } + switch item.Role { + case "first_frame": + firstFrames++ + case "last_frame": + lastFrames++ + case "reference_image": + referenceImages++ + default: + return fmt.Errorf("H3 content item %d has unsupported image role", i) + } + case "video_url": + if item.VideoURL == nil || strings.TrimSpace(item.VideoURL.URL) == "" || item.Role != "reference_video" { + return fmt.Errorf("H3 content item %d has invalid video_url", i) + } + referenceVideos++ + case "audio_url": + if item.AudioURL == nil || strings.TrimSpace(item.AudioURL.URL) == "" || item.Role != "reference_audio" { + return fmt.Errorf("H3 content item %d has invalid audio_url", i) + } + referenceAudios++ + default: + return fmt.Errorf("H3 content item %d has unsupported type", i) + } + } + + if textCount == 0 { + return fmt.Errorf("H3 content must include a non-empty text item") + } + if firstFrames > 1 || lastFrames > 1 { + return fmt.Errorf("H3 accepts at most one first_frame and one last_frame image") + } + if lastFrames > 0 && firstFrames == 0 { + return fmt.Errorf("H3 last_frame requires a first_frame image") + } + if int64(referenceImages) > H3MaxImages || int64(referenceVideos) > H3MaxVideos || int64(referenceAudios) > H3MaxAudios { + return fmt.Errorf("H3 reference media count exceeds the official limit") + } + if (firstFrames > 0 || lastFrames > 0) && (referenceImages > 0 || referenceVideos > 0 || referenceAudios > 0) { + return fmt.Errorf("H3 frame images and reference media cannot be mixed") + } + return nil +} + +func buildH3Content(req *relaycommon.TaskSubmitReq) ([]H3ContentItem, error) { + if req == nil { + return nil, fmt.Errorf("H3 request is required") + } + + rawContent := req.Content + if len(rawContent) == 0 && req.Metadata != nil { + if value, ok := req.Metadata["content"]; ok { + data, err := common.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshal H3 metadata.content: %w", err) + } + if err := common.Unmarshal(data, &rawContent); err != nil { + return nil, fmt.Errorf("decode H3 metadata.content: %w", err) + } + } + } + + if len(rawContent) > 0 { + items := make([]H3ContentItem, 0, len(rawContent)+1) + for _, raw := range rawContent { + item, err := decodeH3ContentItem(raw) + if err != nil { + return nil, err + } + items = append(items, item) + } + hasText := false + for _, item := range items { + if item.Type == "text" && strings.TrimSpace(item.Text) != "" { + hasText = true + break + } + } + if !hasText && strings.TrimSpace(req.Prompt) != "" { + items = append([]H3ContentItem{{Type: "text", Text: req.Prompt}}, items...) + } + if err := validateH3Content(items); err != nil { + return nil, err + } + return items, nil + } + + items := make([]H3ContentItem, 0, 1+len(req.Images)) + if strings.TrimSpace(req.Prompt) != "" { + items = append(items, H3ContentItem{Type: "text", Text: req.Prompt}) + } + + firstFrame, err := h3MetadataMediaURLs(req.Metadata, "first_frame_image") + if err != nil { + return nil, err + } + lastFrame, err := h3MetadataMediaURLs(req.Metadata, "last_frame_image") + if err != nil { + return nil, err + } + if len(firstFrame) == 0 && len(lastFrame) == 0 { + images := append([]string{}, req.Images...) + if len(images) == 0 && strings.TrimSpace(req.InputReference) != "" { + images = []string{req.InputReference} + } + if len(images) > 2 { + return nil, fmt.Errorf("H3 accepts at most two frame images") + } + for i, image := range images { + role := "first_frame" + if i == 1 { + role = "last_frame" + } + items = append(items, H3ContentItem{Type: "image_url", Role: role, ImageURL: &H3MediaURL{URL: strings.TrimSpace(image)}}) + } + } else { + if len(firstFrame) > 1 || len(lastFrame) > 1 { + return nil, fmt.Errorf("H3 accepts at most one first_frame and one last_frame image") + } + if len(firstFrame) == 1 { + items = append(items, H3ContentItem{Type: "image_url", Role: "first_frame", ImageURL: &H3MediaURL{URL: firstFrame[0]}}) + } + if len(lastFrame) == 1 { + items = append(items, H3ContentItem{Type: "image_url", Role: "last_frame", ImageURL: &H3MediaURL{URL: lastFrame[0]}}) + } + } + + if len(req.ReferenceImages) > 0 { + for _, image := range req.ReferenceImages { + items = append(items, H3ContentItem{Type: "image_url", Role: "reference_image", ImageURL: &H3MediaURL{URL: strings.TrimSpace(image)}}) + } + } + referenceImages, err := h3MetadataMediaURLs(req.Metadata, "reference_images") + if err != nil { + return nil, err + } + for _, image := range referenceImages { + items = append(items, H3ContentItem{Type: "image_url", Role: "reference_image", ImageURL: &H3MediaURL{URL: image}}) + } + + for _, key := range []struct { + metadataKey string + role string + typeName string + }{ + {metadataKey: "reference_video", role: "reference_video", typeName: "video_url"}, + {metadataKey: "reference_audio", role: "reference_audio", typeName: "audio_url"}, + } { + media, err := h3MetadataMediaURLs(req.Metadata, key.metadataKey) + if err != nil { + return nil, err + } + for _, value := range media { + item := H3ContentItem{Type: key.typeName, Role: key.role} + switch key.typeName { + case "video_url": + item.VideoURL = &H3MediaURL{URL: value} + case "audio_url": + item.AudioURL = &H3MediaURL{URL: value} + } + items = append(items, item) + } + } + + if err := validateH3Content(items); err != nil { + return nil, err + } + return items, nil +} + +func h3MetadataMediaURLs(metadata map[string]interface{}, key string) ([]string, error) { + if metadata == nil { + return nil, nil + } + value, ok := metadata[key] + if !ok || value == nil { + return nil, nil + } + values := []any{value} + if list, ok := value.([]any); ok { + values = list + } else if list, ok := value.([]string); ok { + values = make([]any, 0, len(list)) + for _, item := range list { + values = append(values, item) + } + } + result := make([]string, 0, len(values)) + for _, item := range values { + url, err := h3MediaURLValue(item) + if err != nil { + return nil, fmt.Errorf("invalid H3 %s: %w", key, err) + } + result = append(result, url) + } + return result, nil +} + +func h3MediaURLValue(value any) (string, error) { + switch typed := value.(type) { + case string: + if url := strings.TrimSpace(typed); url != "" { + return url, nil + } + case map[string]any: + data, err := common.Marshal(typed) + if err != nil { + return "", err + } + var media H3MediaURL + if err := common.Unmarshal(data, &media); err != nil { + return "", err + } + if url := strings.TrimSpace(media.URL); url != "" { + return url, nil + } + } + return "", fmt.Errorf("media URL is empty or not a string/object") +} + +func h3Resolution(req *relaycommon.TaskSubmitReq) (string, error) { + value := strings.TrimSpace(req.Resolution) + if value == "" { + metadataValue, err := metadataString(req.Metadata, "resolution") + if err != nil { + return "", err + } + value = strings.TrimSpace(metadataValue) + } + if value == "" { + value = strings.TrimSpace(req.Size) + } + if value == "" { + return Resolution768P, nil + } + switch strings.ToUpper(value) { + case Resolution768P, "768": + return Resolution768P, nil + case Resolution2K: + return Resolution2K, nil + default: + return "", fmt.Errorf("H3 resolution must be 768P or 2K") + } +} + +func h3Duration(req *relaycommon.TaskSubmitReq) (int, error) { + seconds, err := req.ResolvedSeconds() + if err != nil { + return 0, fmt.Errorf("H3 duration must be an integer: %w", err) + } + duration := H3DefaultDuration + if req.Duration != nil || req.DurationSeconds != nil || strings.TrimSpace(req.Seconds) != "" { + duration = seconds + } + if int64(duration) < H3MinDuration || int64(duration) > H3MaxDuration { + return 0, fmt.Errorf("H3 duration must be between %d and %d seconds", H3MinDuration, H3MaxDuration) + } + return duration, nil +} + +func h3Ratio(req *relaycommon.TaskSubmitReq, content []H3ContentItem) (string, error) { + metadataValue, err := metadataString(req.Metadata, "ratio") + if err != nil { + return "", err + } + value := strings.TrimSpace(metadataValue) + if value == "" && req.Ratio != nil { + value = strings.TrimSpace(*req.Ratio) + } + if value == "" { + value = strings.TrimSpace(req.AspectRatio) + } + value = strings.ToLower(value) + if value != "" { + if _, ok := h3Ratios[value]; !ok { + return "", fmt.Errorf("H3 ratio is invalid") + } + } + + hasFrame := false + hasReference := false + for _, item := range content { + switch item.Role { + case "first_frame", "last_frame": + hasFrame = true + case "reference_image", "reference_video", "reference_audio": + hasReference = true + } + } + if hasFrame { + return "adaptive", nil + } + if value == "" { + if hasReference { + return "adaptive", nil + } + return "16:9", nil + } + if value == "adaptive" && !hasReference { + return "", fmt.Errorf("H3 adaptive ratio requires visual reference content") + } + return value, nil +} + +func metadataString(metadata map[string]interface{}, key string) (string, error) { + if metadata == nil { + return "", nil + } + value, ok := metadata[key] + if !ok { + return "", nil + } + stringValue, ok := value.(string) + if !ok { + return "", fmt.Errorf("H3 %s must be a string", key) + } + return stringValue, nil +} + +func h3OptionalBool(metadata map[string]interface{}, key string) (*bool, error) { + if metadata == nil { + return nil, nil + } + value, ok := metadata[key] + if !ok { + return nil, nil + } + boolValue, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("H3 %s must be boolean", key) + } + return &boolValue, nil +} + +func h3CallbackURL(req *relaycommon.TaskSubmitReq) (*string, error) { + if req.CallbackURL != nil { + value := strings.TrimSpace(*req.CallbackURL) + if value == "" { + return nil, nil + } + return &value, nil + } + raw, exists := req.Metadata["callback_url"] + if !exists || raw == nil { + return nil, nil + } + value, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("H3 callback_url must be a string") + } + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + return &value, nil +} + +func buildH3Request(req *relaycommon.TaskSubmitReq, info *relaycommon.RelayInfo) (*H3VideoRequest, error) { + if isUnsupportedH3Model(h3RequestModel(req, info)) { + return nil, fmt.Errorf("%s is not supported by this adaptor", H3MaxModel) + } + content, err := buildH3Content(req) + if err != nil { + return nil, err + } + duration, err := h3Duration(req) + if err != nil { + return nil, err + } + resolution, err := h3Resolution(req) + if err != nil { + return nil, err + } + ratio, err := h3Ratio(req, content) + if err != nil { + return nil, err + } + + callbackURL, err := h3CallbackURL(req) + if err != nil { + return nil, err + } + watermark := req.Watermark + if watermark == nil { + var boolErr error + watermark, boolErr = h3OptionalBool(req.Metadata, "aigc_watermark") + if boolErr != nil { + return nil, boolErr + } + } + + return &H3VideoRequest{ + Model: h3RequestModel(req, info), + Content: content, + Resolution: resolution, + Duration: duration, + Ratio: ratio, + CallbackURL: callbackURL, + AigcWatermark: watermark, + }, nil +} + +func parseH3Response(body []byte) (*H3QueryResponse, bool, error) { + var response H3QueryResponse + if err := common.Unmarshal(body, &response); err != nil { + return nil, false, err + } + return &response, response.Task != nil || response.Error != nil, nil +} + +func parseH3TaskResult(body []byte) (*relaycommon.TaskInfo, bool, error) { + response, handled, err := parseH3Response(body) + if err != nil || !handled { + return nil, handled, err + } + if response.Error != nil { + code := h3APIErrorCode(response.Error) + message := strings.TrimSpace(response.Error.Message) + if message == "" { + message = "H3 query failed" + } + if code == httpStatusRequestTimeout || code == httpStatusTooManyRequests || code >= 500 { + return nil, true, fmt.Errorf("H3 temporary query error: %s", message) + } + return &relaycommon.TaskInfo{Code: code, Status: model.TaskStatusFailure, Progress: "100%", Reason: message}, true, nil + } + + task := response.Task + if strings.TrimSpace(task.ID) == "" { + return nil, true, fmt.Errorf("H3 query response missing task id") + } + usage := normalizeH3Usage(task.Usage) + result := &relaycommon.TaskInfo{TaskID: task.ID, Code: 0, Usage: usage} + switch strings.ToLower(strings.TrimSpace(task.Status)) { + case "queued": + result.Status = model.TaskStatusQueued + result.Progress = "30%" + case "running": + result.Status = model.TaskStatusInProgress + result.Progress = "50%" + case "succeeded": + result.Progress = "100%" + if task.Content == nil || strings.TrimSpace(task.Content.URL) == "" { + result.Status = model.TaskStatusInProgress + result.Progress = "90%" + } else { + result.Status = model.TaskStatusSuccess + result.Url = strings.TrimSpace(task.Content.URL) + } + case "failed", "cancelled": + result.Status = model.TaskStatusFailure + result.Progress = "100%" + result.Code = h3CodeNumber(task.ErrorCode()) + result.Reason = strings.TrimSpace(task.ErrorMessage()) + if result.Reason == "" { + result.Reason = "H3 task failed" + } + default: + return nil, true, fmt.Errorf("H3 query response has unsupported task status") + } + return result, true, nil +} + +func h3APIErrorCode(apiError *H3APIError) int { + if apiError == nil { + return 0 + } + if code := h3CodeNumber(apiError.HTTPCode); code != 0 { + return code + } + return h3CodeNumber(apiError.Code) +} + +func normalizeH3Usage(usage *H3Usage) *types.TaskUsage { + result := &types.TaskUsage{ + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessMissing, + } + if usage == nil { + return result + } + + total, totalPresent, totalErr := h3SecondsField(usage.TotalSeconds) + input, inputPresent, inputErr := h3SecondsField(usage.InputSeconds) + output, outputPresent, outputErr := h3SecondsField(usage.OutputSeconds) + images, imagesPresent, imagesErr := h3IntegerField(usage.InputImageCount) + audio, audioPresent, audioErr := h3SecondsField(usage.InputAudioSeconds) + invalid := totalErr != nil || inputErr != nil || outputErr != nil || imagesErr != nil || audioErr != nil + + if totalPresent { + invalid = invalid || total.IsNegative() || total.GreaterThan(decimal.NewFromInt(H3MaxDuration+H3MaxInputVideoSeconds)) + } + if inputPresent { + invalid = invalid || input.IsNegative() || input.GreaterThan(decimal.NewFromInt(H3MaxInputVideoSeconds)) + if inputErr == nil { + result.InputVideoDurationMs = h3SecondsToMilliseconds(input) + } + } + if outputPresent { + invalid = invalid || output.LessThan(decimal.NewFromInt(H3MinDuration)) || output.GreaterThan(decimal.NewFromInt(H3MaxDuration)) + if outputErr == nil { + result.OutputDurationMs = h3SecondsToMilliseconds(output) + } + } + if imagesPresent { + invalid = invalid || images < 0 || images > H3MaxImages + if imagesErr == nil { + result.InputImageCount = int64Pointer(images) + } + } + if audioPresent { + invalid = invalid || audio.IsNegative() || audio.GreaterThan(decimal.NewFromInt(H3MaxInputVideoSeconds)) + if audioErr == nil { + result.InputAudioDurationMs = h3SecondsToMilliseconds(audio) + } + } + if totalPresent && inputPresent && outputPresent { + invalid = invalid || !total.Equal(input.Add(output)) + } + + presentCount := 0 + for _, present := range []bool{totalPresent, inputPresent, outputPresent, imagesPresent, audioPresent} { + if present { + presentCount++ + } + } + if invalid { + result.Completeness = types.TaskUsageCompletenessInvalid + } else if presentCount == 0 { + result.Completeness = types.TaskUsageCompletenessMissing + } else if outputPresent && imagesPresent { + result.Completeness = types.TaskUsageCompletenessComplete + } else { + result.Completeness = types.TaskUsageCompletenessPartial + } + return result +} + +func h3IntegerField(raw json.RawMessage) (int64, bool, error) { + value, present, err := h3DecimalField(raw) + if err != nil || !present { + return 0, present, err + } + if !value.Equal(value.Truncate(0)) || !value.IsInteger() { + return 0, true, fmt.Errorf("value must be an integer") + } + if value.LessThan(decimal.NewFromInt(math.MinInt64)) || value.GreaterThan(decimal.NewFromInt(math.MaxInt64)) { + return 0, true, fmt.Errorf("value is out of range") + } + return value.IntPart(), true, nil +} + +func h3SecondsField(raw json.RawMessage) (decimal.Decimal, bool, error) { + value, present, err := h3DecimalField(raw) + if err != nil || !present { + return decimal.Zero, present, err + } + if value.IsNegative() { + return decimal.Zero, true, fmt.Errorf("value cannot be negative") + } + milliseconds := value.Mul(decimal.NewFromInt(1000)) + if !milliseconds.Equal(milliseconds.Truncate(0)) { + return decimal.Zero, true, fmt.Errorf("seconds must be precise to milliseconds") + } + if milliseconds.GreaterThan(decimal.NewFromInt(math.MaxInt64)) { + return decimal.Zero, true, fmt.Errorf("seconds are out of range") + } + return value, true, nil +} + +func h3DecimalField(raw json.RawMessage) (decimal.Decimal, bool, error) { + value := bytes.TrimSpace(raw) + if len(value) == 0 || bytes.Equal(value, []byte("null")) { + return decimal.Zero, false, nil + } + if common.GetJsonType(value) == "string" { + var stringValue string + if err := common.Unmarshal(value, &stringValue); err != nil { + return decimal.Zero, true, err + } + value = bytes.TrimSpace([]byte(stringValue)) + } else if common.GetJsonType(value) != "number" { + return decimal.Zero, true, fmt.Errorf("value must be a number") + } + result, err := decimal.NewFromString(string(value)) + if err != nil { + return decimal.Zero, true, err + } + return result, true, nil +} + +func h3SecondsToMilliseconds(seconds decimal.Decimal) *int64 { + milliseconds := seconds.Mul(decimal.NewFromInt(1000)) + if seconds.IsNegative() || !milliseconds.Equal(milliseconds.Truncate(0)) || milliseconds.GreaterThan(decimal.NewFromInt(math.MaxInt64)) { + return nil + } + value := milliseconds.IntPart() + return int64Pointer(value) +} + +func int64Pointer(value int64) *int64 { + return &value +} + +func h3CodeNumber(value any) int { + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil { + return parsed + } + } + return 0 +} + +func (t *H3Task) ErrorCode() any { + if t == nil || t.Error == nil { + return nil + } + return t.Error.Code +} + +func (t *H3Task) ErrorMessage() string { + if t == nil || t.Error == nil { + return "" + } + return t.Error.Message +} + +const ( + httpStatusRequestTimeout = 408 + httpStatusTooManyRequests = 429 +) diff --git a/relay/channel/task/hailuo/models.go b/relay/channel/task/hailuo/models.go index 09a97766..8426e26e 100644 --- a/relay/channel/task/hailuo/models.go +++ b/relay/channel/task/hailuo/models.go @@ -1,5 +1,7 @@ package hailuo +import "encoding/json" + type SubjectReference struct { Type string `json:"type"` // Subject type, currently only supports "character" Image []string `json:"image"` // Array of subject reference images (currently only supports single image) @@ -19,6 +21,29 @@ type VideoRequest struct { SubjectReference []SubjectReference `json:"subject_reference,omitempty"` // For subject-reference-to-video } +type H3MediaURL struct { + URL string `json:"url"` +} + +type H3ContentItem struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *H3MediaURL `json:"image_url,omitempty"` + VideoURL *H3MediaURL `json:"video_url,omitempty"` + AudioURL *H3MediaURL `json:"audio_url,omitempty"` + Role string `json:"role,omitempty"` +} + +type H3VideoRequest struct { + Model string `json:"model"` + Content []H3ContentItem `json:"content"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio"` + CallbackURL *string `json:"callback_url,omitempty"` + AigcWatermark *bool `json:"aigc_watermark,omitempty"` +} + type VideoResponse struct { TaskID string `json:"task_id"` BaseResp BaseResp `json:"base_resp"` @@ -42,6 +67,47 @@ type QueryTaskResponse struct { BaseResp BaseResp `json:"base_resp"` } +type H3QueryResponse struct { + Task *H3Task `json:"task,omitempty"` + Error *H3APIError `json:"error,omitempty"` +} + +type H3Task struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Content *H3TaskContent `json:"content,omitempty"` + Resolution string `json:"resolution,omitempty"` + Duration *int64 `json:"duration,omitempty"` + Usage *H3Usage `json:"usage,omitempty"` + Error *H3TaskError `json:"error,omitempty"` +} + +type H3TaskContent struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +type H3TaskError struct { + Code any `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type H3APIError struct { + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` + Code any `json:"code,omitempty"` + HTTPCode any `json:"http_code,omitempty"` +} + +type H3Usage struct { + TotalSeconds json.RawMessage `json:"total_seconds"` + InputSeconds json.RawMessage `json:"input_seconds"` + OutputSeconds json.RawMessage `json:"output_seconds"` + InputImageCount json.RawMessage `json:"input_image_count"` + InputAudioSeconds json.RawMessage `json:"input_audio_seconds"` +} + type ErrorInfo struct { StatusCode int `json:"status_code"` StatusMsg string `json:"status_msg"` @@ -81,6 +147,12 @@ type FileObject struct { func GetModelConfig(model string) ModelConfig { configs := map[string]ModelConfig{ + H3Model: { + Name: H3Model, + DefaultResolution: Resolution768P, + SupportedDurations: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + SupportedResolutions: []string{Resolution768P, Resolution2K}, + }, "MiniMax-Hailuo-2.3": { Name: "MiniMax-Hailuo-2.3", DefaultResolution: Resolution768P, diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index fe97b94e..74c3b174 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -163,9 +163,10 @@ type RelayInfo struct { AuditResponseContent string AuditResponseTruncated bool - PriceData types.PriceData - TaskBilling *types.TaskBillingResult - ToolUsage *ToolUsageLedger + PriceData types.PriceData + TaskBilling *types.TaskBillingResult + TaskBillingPlan *types.TaskBillingPlan + ToolUsage *ToolUsageLedger // QuotaClamp is set when a quota conversion saturated at the int32 bound // or fell back from NaN while computing this request's charge. @@ -834,6 +835,9 @@ func (t *TaskSubmitReq) ResolvedSeconds() (int, error) { return 0, nil } seconds := t.DurationValue() + if seconds == 0 && t.DurationSeconds != nil { + seconds = *t.DurationSeconds + } if seconds == 0 && t.Seconds != "" { parsed, err := strconv.Atoi(t.Seconds) if err != nil { @@ -921,15 +925,16 @@ func (t *TaskSubmitReq) UnmarshalMetadata(v any) error { } type TaskInfo struct { - Code int `json:"code"` - TaskID string `json:"task_id"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Url string `json:"url,omitempty"` - RemoteUrl string `json:"remote_url,omitempty"` - Progress string `json:"progress,omitempty"` - CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 - TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 + Code int `json:"code"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Url string `json:"url,omitempty"` + RemoteUrl string `json:"remote_url,omitempty"` + Progress string `json:"progress,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 + TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 + Usage *types.TaskUsage `json:"usage,omitempty"` } func FailTaskInfo(reason string) *TaskInfo { diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index 3cf83d0b..82836f68 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -92,6 +92,23 @@ func TestTaskSubmitReqResolvedSeconds(t *testing.T) { _, err = req.ResolvedSeconds() require.Error(t, err) require.Contains(t, err.Error(), "invalid seconds value: abc") + + durationSeconds := 10 + req = TaskSubmitReq{DurationSeconds: &durationSeconds, Seconds: "12"} + seconds, err = req.ResolvedSeconds() + require.NoError(t, err) + require.Equal(t, 10, seconds) + + zeroDuration := 0 + req = TaskSubmitReq{Duration: &zeroDuration, DurationSeconds: &durationSeconds, Seconds: "12"} + seconds, err = req.ResolvedSeconds() + require.NoError(t, err) + require.Equal(t, 10, seconds) + + req = TaskSubmitReq{Duration: &duration, DurationSeconds: &durationSeconds, Seconds: "12"} + seconds, err = req.ResolvedSeconds() + require.NoError(t, err) + require.Equal(t, 8, seconds) } func TestTaskSubmitReqResolvedSecondsOrDefault(t *testing.T) { diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index 1cd1cd08..4ffaba7a 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -112,6 +112,46 @@ func validatePrompt(prompt string) *dto.TaskError { return nil } +func hasTaskContentText(content []map[string]any) bool { + for _, item := range content { + typeName, _ := item["type"].(string) + text, _ := item["text"].(string) + if strings.EqualFold(strings.TrimSpace(typeName), "text") && strings.TrimSpace(text) != "" { + return true + } + } + return false +} + +func allowsTaskContentWithoutPrompt(info *RelayInfo, req TaskSubmitReq) bool { + if info == nil { + return false + } + + channelType := 0 + if info.ChannelMeta != nil { + channelType = info.ChannelMeta.ChannelType + } + switch channelType { + case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeVolcEngine: + return true + case constant.ChannelTypeMiniMax: + model := "" + if info.ChannelMeta != nil { + model = strings.TrimSpace(info.ChannelMeta.UpstreamModelName) + } + if model == "" { + model = strings.TrimSpace(info.OriginModelName) + } + if model == "" { + model = strings.TrimSpace(req.Model) + } + return strings.EqualFold(model, constant.TaskModelMiniMaxH3) + default: + return false + } +} + // MaxTaskDurationSeconds caps user-supplied video duration before it becomes // an OtherRatio billing multiplier. const MaxTaskDurationSeconds = 3600 @@ -267,7 +307,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d return createTaskError(err, "invalid_request", http.StatusBadRequest, true) } - if taskErr := validatePrompt(req.Prompt); taskErr != nil { + if strings.TrimSpace(req.Prompt) == "" && hasTaskContentText(req.Content) && allowsTaskContentWithoutPrompt(info, req) { + // H3 and Doubao consume the structured content field directly. + } else if taskErr := validatePrompt(req.Prompt); taskErr != nil { return taskErr } diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go index 7db18dee..6946634b 100644 --- a/relay/common/relay_utils_test.go +++ b/relay/common/relay_utils_test.go @@ -168,3 +168,57 @@ func TestValidateBasicTaskRequestNormalizesImageField(t *testing.T) { require.Equal(t, []string{"https://example.com/first.png"}, storedReq.Images) require.Equal(t, constant.TaskActionGenerate, info.Action) } + +func TestValidateBasicTaskRequestScopesContentOnlyException(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + channelType int + upstreamModel string + requestModel string + wantErr bool + }{ + {name: "H3 content", channelType: constant.ChannelTypeMiniMax, upstreamModel: "MiniMax-H3"}, + {name: "H3 request model fallback", channelType: constant.ChannelTypeMiniMax, requestModel: "MiniMax-H3"}, + {name: "Doubao content", channelType: constant.ChannelTypeDoubaoVideo}, + {name: "VolcEngine Doubao content", channelType: constant.ChannelTypeVolcEngine}, + {name: "legacy Hailuo requires prompt", channelType: constant.ChannelTypeMiniMax, upstreamModel: "MiniMax-Hailuo-2.3", wantErr: true}, + {name: "Gemini requires prompt", channelType: constant.ChannelTypeGemini, upstreamModel: "veo-3.0-generate-001", wantErr: true}, + {name: "Kling requires prompt", channelType: constant.ChannelTypeKling, upstreamModel: "kling-v2", wantErr: true}, + {name: "Jimeng requires prompt", channelType: constant.ChannelTypeJimeng, upstreamModel: "jimeng-video", wantErr: true}, + {name: "Vertex requires prompt", channelType: constant.ChannelTypeVertexAi, upstreamModel: "veo-3.0-generate-001", wantErr: true}, + {name: "Vidu requires prompt", channelType: constant.ChannelTypeVidu, upstreamModel: "viduq1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requestModel := tt.requestModel + if requestModel == "" { + requestModel = "MiniMax-H3" + } + request := httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model":"`+requestModel+`", + "content":[{"type":"text","text":"Create a short film"}] + }`)) + request.Header.Set("Content-Type", "application/json") + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = request + info := &RelayInfo{ + TaskRelayInfo: &TaskRelayInfo{}, + ChannelMeta: &ChannelMeta{ + ChannelType: tt.channelType, + UpstreamModelName: tt.upstreamModel, + }, + } + + taskErr := ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) + if tt.wantErr { + require.NotNil(t, taskErr) + require.Contains(t, taskErr.Message, "prompt is required") + return + } + require.Nil(t, taskErr) + }) + } +} diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index 4c372a04..4a0b239c 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -14,6 +14,7 @@ import ( "github.com/MAX-API-Next/MAX-API/setting/config" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" + "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" "github.com/MAX-API-Next/MAX-API/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -131,6 +132,49 @@ func TestModelPriceHelperPerCallRejectsUnpricedMJSunoTaskModels(t *testing.T) { } } +func TestModelPriceHelperPerCallDoesNotTreatStructuredMinimaxCardAsLegacyPrice(t *testing.T) { + gin.SetMode(gin.TestMode) + originalSelfUseMode := operation_setting.SelfUseModeEnabled + originalModelPrice := ratio_setting.ModelPrice2JSONString() + originalModelRatio := ratio_setting.ModelRatio2JSONString() + originalGroupRatio := ratio_setting.GroupRatio2JSONString() + originalRateCards, err := common.Marshal(task_billing_setting.GetRateCardsCopy()) + require.NoError(t, err) + t.Cleanup(func() { + operation_setting.SelfUseModeEnabled = originalSelfUseMode + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(originalModelPrice)) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(originalModelRatio)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalGroupRatio)) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": string(originalRateCards), + })) + }) + + operation_setting.SelfUseModeEnabled = false + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1}`)) + require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{ + "task_billing_setting.rate_cards": `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}`, + })) + + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + UserGroup: "default", + UsingGroup: "default", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax-H3", + }, + } + + priceData, err := ModelPriceHelperPerCall(ctx, info) + + require.Error(t, err) + require.Contains(t, err.Error(), "not been priced") + require.Zero(t, priceData) +} + func TestModelPriceHelperPerCallUsesDefaultTaskPrice(t *testing.T) { gin.SetMode(gin.TestMode) originalQuotaPerUnit := common.QuotaPerUnit diff --git a/relay/relay_task.go b/relay/relay_task.go index 8a6502bb..f5db731f 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -59,6 +59,7 @@ func populateTaskBillingMetadata(task *model.Task, info *relaycommon.RelayInfo) ModelRatio: info.PriceData.ModelRatio, OtherRatios: info.PriceData.OtherRatios, TaskBilling: info.TaskBilling, + TaskBillingPlan: types.CloneTaskBillingPlan(info.TaskBillingPlan), OriginModelName: info.OriginModelName, PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName) || info.PriceData.UsePrice || info.TaskBilling != nil, DeltaSettlementDisabled: common.GetPointer(deltaSettlementDisabled), @@ -267,21 +268,25 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe return nil, service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest) } adaptor.Init(info) + 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 taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil { return nil, taskErr } // 2. 确定模型名称 - modelName := info.OriginModelName 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(仅首次) @@ -303,6 +308,10 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } info.PriceData = priceData + // H3 uses a bounded multi-component plan. During H3-02 this is a shadow + // snapshot only; legacy submission settlement remains unchanged. + captureShadowTaskBillingPlan(c, info, adaptor) + // 6. Prefer a parameterized rate card when the adaptor can normalize the // request. Legacy task models continue to use OtherRatios. taskBillingOverride := false @@ -431,6 +440,23 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe }, nil } +func captureShadowTaskBillingPlan(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.TaskAdaptor) { + if info == nil { + return + } + info.TaskBillingPlan = nil + planner, ok := adaptor.(channel.TaskBillingPlanProvider) + if !ok { + return + } + plan, err := planner.BuildTaskBillingPlan(c, info) + if err != nil { + common.SysLog("task_billing_plan_error: " + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(err.Error()))) + return + } + info.TaskBillingPlan = plan +} + func setTaskOtherRatioHeaders(header http.Header, otherRatios map[string]float64) { if header == nil { return diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index c3ccdad5..cf049c19 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -13,6 +13,7 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/relay/channel/task/doubao" + "github.com/MAX-API-Next/MAX-API/relay/channel/task/hailuo" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/setting/config" "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" @@ -28,6 +29,40 @@ type recordingTaskReservationBilling struct { settleTo []int } +func TestPopulateTaskBillingMetadataPersistsH3PlanSnapshot(t *testing.T) { + plan := &types.TaskBillingPlan{ + Source: "minimax_hailuo", + RuleKey: "minimax_h3_v2", + SchemaVersion: 1, + Mode: types.TaskBillingPlanModeBoundedActual, + ConfigHash: "sha256:test", + Currency: "USD", + GroupRatio: 1, + QuotaPerUnit: 1000, + Resolution: "768P", + ReserveQuota: 800, + } + info := &relaycommon.RelayInfo{ + RequestId: "h3-plan-snapshot", + OriginModelName: "MiniMax-H3", + TaskBillingPlan: plan, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + } + task := &model.Task{} + populateTaskBillingMetadata(task, info) + require.Equal(t, plan, task.PrivateData.BillingContext.TaskBillingPlan) + require.NotSame(t, plan, task.PrivateData.BillingContext.TaskBillingPlan) + plan.ReserveQuota = 999 + require.EqualValues(t, 800, task.PrivateData.BillingContext.TaskBillingPlan.ReserveQuota) + plan.ReserveQuota = 800 + + data, err := common.Marshal(task.PrivateData) + require.NoError(t, err) + var restored model.TaskPrivateData + require.NoError(t, common.Unmarshal(data, &restored)) + require.Equal(t, plan, restored.BillingContext.TaskBillingPlan) +} + func (b *recordingTaskReservationBilling) Settle(actualQuota int) error { b.settleTo = append(b.settleTo, actualQuota) b.preConsumed = actualQuota @@ -166,6 +201,109 @@ func TestPrepareTaskSubmitRequestBodyMakesParamOverrideVisibleToBilling(t *testi assert.InDelta(t, 51.0/46.0, ratios["video_input"], 1e-9) } +func TestPrepareTaskSubmitRequestBodyMakesH3PlanUseFinalRequestFacts(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model":"MiniMax-H3", + "prompt":"test", + "duration":5, + "resolution":"768P", + "content":[{"type":"text","text":"test"}] + }`)) + c.Request.Header.Set("Content-Type", "application/json") + + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax-H3", + ParamOverride: map[string]interface{}{ + "operations": []interface{}{ + map[string]interface{}{"path": "duration", "mode": "set", "value": 10}, + map[string]interface{}{"path": "resolution", "mode": "set", "value": "2K"}, + }, + }, + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionGenerate}, + PriceData: types.PriceData{GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}}, + } + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, relaycommon.TaskSubmitReq{ + Model: "MiniMax-H3", + Prompt: "test", + Duration: common.GetPointer(5), + Resolution: "768P", + Content: []map[string]any{{"type": "text", "text": "test"}}, + }) + + _, taskErr := prepareTaskSubmitRequestBody(c, info, &hailuo.TaskAdaptor{}) + require.Nil(t, taskErr) + + plan, err := (&hailuo.TaskAdaptor{}).BuildTaskBillingPlan(c, info) + require.NoError(t, err) + require.EqualValues(t, 10, plan.RequestedOutputDurationSeconds) + require.Equal(t, "2K", plan.Resolution) +} + +func TestCaptureShadowTaskBillingPlanFailureDoesNotBlockSubmission(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + previous := &types.TaskBillingPlan{RuleKey: "stale"} + info := &relaycommon.RelayInfo{ + OriginModelName: hailuo.H3Model, + TaskBillingPlan: previous, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: hailuo.H3Model, + }, + } + + captureShadowTaskBillingPlan(c, info, &hailuo.TaskAdaptor{}) + + require.Nil(t, info.TaskBillingPlan) +} + +func TestRelayTaskSubmitMapsModelBeforeContentOnlyValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{ + "model":"MiniMax-H3", + "content":[{"type":"text","text":"Create a short film"}] + }`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("channel_type", constant.ChannelTypeMiniMax) + c.Set("model_mapping", `{"MiniMax-H3":"MiniMax-Hailuo-2.3"}`) + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax-H3", + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + } + + _, taskErr := RelayTaskSubmit(c, info) + + require.NotNil(t, taskErr) + require.Contains(t, taskErr.Message, "prompt is required") +} + +func TestCaptureShadowTaskBillingPlanSuccessKeepsSnapshot(t *testing.T) { + duration := 5 + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("task_request", relaycommon.TaskSubmitReq{ + Model: hailuo.H3Model, + Prompt: "test", + Duration: &duration, + Content: []map[string]any{{"type": "text", "text": "test"}}, + }) + info := &relaycommon.RelayInfo{ + OriginModelName: hailuo.H3Model, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: hailuo.H3Model, + }, + PriceData: types.PriceData{GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1}}, + } + + captureShadowTaskBillingPlan(c, info, &hailuo.TaskAdaptor{}) + + require.NotNil(t, info.TaskBillingPlan) + require.EqualValues(t, 5, info.TaskBillingPlan.RequestedOutputDurationSeconds) +} + func TestPrepareTaskSubmitRequestBodyMakesMultipartParamOverrideVisibleToBilling(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/router/api-router.go b/router/api-router.go index 031ebb49..4d333fb6 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -205,6 +205,7 @@ func SetApiRouter(router *gin.Engine) { { optionRoute.GET("/", controller.GetOptions) optionRoute.PUT("/", controller.UpdateOption) + optionRoute.POST("/h3_billing/preview", controller.PreviewH3Billing) optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance) optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats) optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) diff --git a/router/api_router_test.go b/router/api_router_test.go index 0f9f3098..f504d8a0 100644 --- a/router/api_router_test.go +++ b/router/api_router_test.go @@ -130,6 +130,19 @@ func TestPermissionCatalogRouteIsRegistered(t *testing.T) { t.Fatal("expected GET /api/authz/catalog route to be registered") } +func TestH3BillingPreviewRouteIsRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + SetApiRouter(engine) + + for _, route := range engine.Routes() { + if route.Method == http.MethodPost && route.Path == "/api/option/h3_billing/preview" { + return + } + } + t.Fatal("expected POST /api/option/h3_billing/preview route to be registered") +} + func TestHealthRoutesAreRegistered(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/setting/task_billing_setting/h3.go b/setting/task_billing_setting/h3.go new file mode 100644 index 00000000..6c2d0a04 --- /dev/null +++ b/setting/task_billing_setting/h3.go @@ -0,0 +1,650 @@ +package task_billing_setting + +import ( + "crypto/sha256" + "fmt" + "math" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/shopspring/decimal" +) + +const ( + H3BillingSource = "minimax" + MinimaxBillingRuleKey = "minimax/minimax-h3" + MinimaxBillingType = RateCardBillingTypeMinimax + LegacyH3BillingSource = "minimax_hailuo" + H3BillingProfileKey = "minimax_h3_v2" // Deprecated legacy option key. + H3BillingMode = types.TaskBillingPlanModeBoundedActual + + H3MinOutputDurationSeconds int64 = 4 + H3MaxOutputDurationSeconds int64 = 15 + H3MaxInputMediaDurationSeconds int64 = 15 + H3MaxInputVideoCount int64 = 3 + H3MaxInputAudioCount int64 = 3 + H3MaxInputImageCount int64 = 9 +) + +type H3BillingConfig struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Currency string `json:"currency"` + OutputUnitPrice map[string]string `json:"output_unit_price"` + InputVideoUnitPrice map[string]string `json:"input_video_unit_price"` + InputVideoMaxSeconds int64 `json:"input_video_max_seconds"` + InputImageFreeCount int64 `json:"input_image_free_count"` + InputImageExtraUnitPrice string `json:"input_image_extra_unit_price"` + InputAudioUnitPrice string `json:"input_audio_unit_price"` +} + +// H3BillingInput contains only request facts normalized by the H3 adaptor. +// It deliberately does not contain raw provider JSON or media contents. +type H3BillingInput struct { + Resolution string + OutputDurationSeconds int64 + InputVideoCount int64 + InputAudioCount int64 + InputImageCount int64 +} + +type H3BillingQuoteComponent struct { + Key string `json:"key"` + Unit string `json:"unit"` + Quantity int64 `json:"quantity"` + QuantityDecimal string `json:"quantity_decimal,omitempty"` + UnitPrice string `json:"unit_price"` + Price string `json:"price"` +} + +type H3BillingQuote struct { + Stage string `json:"stage"` + Price string `json:"price"` + Quota int `json:"quota"` + OutputSeconds int64 `json:"output_seconds"` + InputVideoSeconds int64 `json:"input_video_seconds"` + InputAudioSeconds int64 `json:"input_audio_seconds"` + OutputDurationMs int64 `json:"output_duration_ms"` + InputVideoDurationMs int64 `json:"input_video_duration_ms"` + InputAudioDurationMs int64 `json:"input_audio_duration_ms"` + InputImageCount int64 `json:"input_image_count"` + Components []H3BillingQuoteComponent `json:"components"` +} + +type H3BillingPreview struct { + ConfigHash string `json:"config_hash"` + QuotaPerUnit float64 `json:"quota_per_unit"` + GroupRatio float64 `json:"group_ratio"` + Estimate *H3BillingQuote `json:"estimate"` + Reserve *H3BillingQuote `json:"reserve"` + Final *H3BillingQuote `json:"final,omitempty"` + AdjustmentQuota *int `json:"adjustment_quota,omitempty"` + RefundQuota *int `json:"refund_quota,omitempty"` +} + +func defaultH3BillingProfiles() map[string]H3BillingConfig { + return map[string]H3BillingConfig{ + H3BillingProfileKey: { + SchemaVersion: 1, + Mode: H3BillingMode, + Currency: "USD", + OutputUnitPrice: map[string]string{"768P": "0.08", "2K": "0.13"}, + InputVideoUnitPrice: map[string]string{"768P": "0.08", "2K": "0.13"}, + InputVideoMaxSeconds: H3MaxInputMediaDurationSeconds, + InputImageFreeCount: 5, + InputImageExtraUnitPrice: "0.04", + InputAudioUnitPrice: "0", + }, + } +} + +func defaultH3BillingConfig() H3BillingConfig { + return defaultH3BillingProfiles()[H3BillingProfileKey] +} + +func encodeBillingConfig(config H3BillingConfig) map[string]any { + data, err := common.Marshal(config) + if err != nil { + return nil + } + var result map[string]any + if err := common.Unmarshal(data, &result); err != nil { + return nil + } + return result +} + +func decodeMinimaxBillingConfig(raw map[string]any) (H3BillingConfig, error) { + if len(raw) == 0 { + return H3BillingConfig{}, fmt.Errorf("MiniMax billing_config cannot be empty") + } + data, err := common.Marshal(raw) + if err != nil { + return H3BillingConfig{}, fmt.Errorf("marshal MiniMax billing_config: %w", err) + } + var config H3BillingConfig + if err := common.Unmarshal(data, &config); err != nil { + return H3BillingConfig{}, fmt.Errorf("decode MiniMax billing_config: %w", err) + } + return config, nil +} + +func isMinimaxH3Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), constant.TaskModelMiniMaxH3) +} + +func newH3BillingProfileMap(profiles map[string]H3BillingConfig) *types.RWMap[string, H3BillingConfig] { + m := types.NewRWMap[string, H3BillingConfig]() + m.AddAll(profiles) + return m +} + +func GetH3BillingProfilesCopy() map[string]H3BillingConfig { + if taskBillingSetting.H3Profiles == nil { + return map[string]H3BillingConfig{} + } + profiles := taskBillingSetting.H3Profiles.ReadAll() + result := make(map[string]H3BillingConfig, len(profiles)) + for key, profile := range profiles { + result[key] = cloneH3BillingConfig(profile) + } + return result +} + +func GetH3BillingProfileCopy(key string) (*H3BillingConfig, bool) { + if taskBillingSetting.H3Profiles == nil { + return nil, false + } + profile, ok := taskBillingSetting.H3Profiles.Get(strings.TrimSpace(key)) + if !ok { + return nil, false + } + profile = cloneH3BillingConfig(profile) + return &profile, true +} + +func ValidateH3ProfilesJSON(raw string) error { + if strings.TrimSpace(raw) == "" { + return fmt.Errorf("H3 billing profiles cannot be empty") + } + var profiles map[string]H3BillingConfig + if err := common.UnmarshalJsonStr(raw, &profiles); err != nil { + return err + } + return validateH3Profiles(profiles) +} + +func validateH3Profiles(profiles map[string]H3BillingConfig) error { + for key, profile := range profiles { + if err := validateH3BillingConfig(key, profile); err != nil { + return err + } + } + if _, ok := profiles[H3BillingProfileKey]; !ok { + return fmt.Errorf("H3 billing profile %s is required", H3BillingProfileKey) + } + return nil +} + +func validateH3BillingConfig(key string, profile H3BillingConfig) error { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("H3 billing profile key cannot be empty") + } + if profile.SchemaVersion != 1 { + return fmt.Errorf("H3 billing profile %s has unsupported schema_version", key) + } + if profile.Mode != H3BillingMode { + return fmt.Errorf("H3 billing profile %s has unsupported mode", key) + } + if strings.TrimSpace(profile.Currency) == "" { + return fmt.Errorf("H3 billing profile %s currency cannot be empty", key) + } + if profile.InputVideoMaxSeconds <= 0 || profile.InputVideoMaxSeconds > H3MaxInputMediaDurationSeconds { + return fmt.Errorf("H3 billing profile %s input_video_max_seconds must be between 1 and %d", key, H3MaxInputMediaDurationSeconds) + } + if profile.InputImageFreeCount < 0 || profile.InputImageFreeCount > H3MaxInputImageCount { + return fmt.Errorf("H3 billing profile %s input_image_free_count must be between 0 and %d", key, H3MaxInputImageCount) + } + if err := validateResolutionPrices(key, "output_unit_price", profile.OutputUnitPrice); err != nil { + return err + } + if err := validateResolutionPrices(key, "input_video_unit_price", profile.InputVideoUnitPrice); err != nil { + return err + } + for name, price := range map[string]string{ + "input_image_extra_unit_price": profile.InputImageExtraUnitPrice, + "input_audio_unit_price": profile.InputAudioUnitPrice, + } { + parsedPrice, err := nonNegativePrice(price) + if err != nil { + return fmt.Errorf("H3 billing profile %s %s: %w", key, name, err) + } + if name == "input_audio_unit_price" && !parsedPrice.IsZero() { + return fmt.Errorf("H3 billing profile %s input_audio_unit_price must be zero in schema version 1", key) + } + } + return nil +} + +func validateResolutionPrices(key, field string, prices map[string]string) error { + for _, resolution := range []string{"768P", "2K"} { + price, ok := prices[resolution] + if !ok { + return fmt.Errorf("H3 billing profile %s %s must define %s", key, field, resolution) + } + if _, err := nonNegativePrice(price); err != nil { + return fmt.Errorf("H3 billing profile %s %s[%s]: %w", key, field, resolution, err) + } + } + return nil +} + +func nonNegativePrice(value string) (decimal.Decimal, error) { + value = strings.TrimSpace(value) + if value == "" { + return decimal.Zero, fmt.Errorf("price cannot be empty") + } + price, err := decimal.NewFromString(value) + if err != nil { + return decimal.Zero, fmt.Errorf("price must be a decimal string") + } + if price.IsNegative() { + return decimal.Zero, fmt.Errorf("price cannot be negative") + } + return price, nil +} + +func BuildH3BillingPlan(input H3BillingInput, groupRatio float64) (*types.TaskBillingPlan, error) { + profile, ok := GetH3BillingProfileCopy(H3BillingProfileKey) + if !ok { + return nil, fmt.Errorf("H3 billing profile %s is not configured", H3BillingProfileKey) + } + return buildH3BillingPlan(*profile, input, groupRatio, LegacyH3BillingSource, H3BillingProfileKey) +} + +// BuildH3BillingPlanForModels selects an explicitly configured structured +// MiniMax rule from the unified task rate-card option. The legacy profile is +// the fallback when no unified MiniMax card was saved. +func BuildH3BillingPlanForModels(input H3BillingInput, groupRatio float64, models ...string) (*types.TaskBillingPlan, error) { + card, key := findMinimaxRateCard(models...) + if card != nil { + profile, err := decodeMinimaxBillingConfig(card.BillingConfig) + if err != nil { + return nil, fmt.Errorf("rate card %s: %w", key, err) + } + return buildH3BillingPlan(profile, input, groupRatio, H3BillingSource, key) + } + return BuildH3BillingPlan(input, groupRatio) +} + +func PreviewH3Billing(config H3BillingConfig, input H3BillingInput, groupRatio float64, usage *types.TaskUsage) (*H3BillingPreview, error) { + plan, err := buildH3BillingPlan(config, input, groupRatio, LegacyH3BillingSource, H3BillingProfileKey) + if err != nil { + return nil, err + } + estimate, err := QuoteH3Estimate(plan) + if err != nil { + return nil, err + } + reserve, err := QuoteH3Reserve(plan) + if err != nil { + return nil, err + } + preview := &H3BillingPreview{ + ConfigHash: plan.ConfigHash, + QuotaPerUnit: plan.QuotaPerUnit, + GroupRatio: plan.GroupRatio, + Estimate: estimate, + Reserve: reserve, + } + if usage == nil { + return preview, nil + } + finalQuote, err := QuoteH3Final(plan, usage) + if err != nil { + return nil, err + } + adjustmentQuota := finalQuote.Quota - reserve.Quota + refundQuota := reserve.Quota - finalQuote.Quota + if refundQuota < 0 { + refundQuota = 0 + } + preview.Final = finalQuote + preview.AdjustmentQuota = &adjustmentQuota + preview.RefundQuota = &refundQuota + return preview, nil +} + +func buildH3BillingPlan(profile H3BillingConfig, input H3BillingInput, groupRatio float64, source, ruleKey string) (*types.TaskBillingPlan, error) { + if err := validateH3BillingConfig(ruleKey, profile); err != nil { + return nil, err + } + resolution := strings.ToUpper(strings.TrimSpace(input.Resolution)) + if resolution != "768P" && resolution != "2K" { + return nil, fmt.Errorf("unsupported H3 resolution %q", input.Resolution) + } + if input.OutputDurationSeconds < H3MinOutputDurationSeconds || input.OutputDurationSeconds > H3MaxOutputDurationSeconds || + input.InputVideoCount < 0 || input.InputVideoCount > H3MaxInputVideoCount || + input.InputAudioCount < 0 || input.InputAudioCount > H3MaxInputAudioCount || + input.InputImageCount < 0 || input.InputImageCount > H3MaxInputImageCount { + return nil, fmt.Errorf("H3 billing request facts are invalid") + } + if math.IsNaN(groupRatio) || math.IsInf(groupRatio, 0) || groupRatio < 0 { + return nil, fmt.Errorf("H3 group ratio must be finite and non-negative") + } + if math.IsNaN(common.QuotaPerUnit) || math.IsInf(common.QuotaPerUnit, 0) || common.QuotaPerUnit <= 0 { + return nil, fmt.Errorf("H3 quota per unit must be finite and positive") + } + + videoReserve := int64(0) + if input.InputVideoCount > 0 { + videoReserve = profile.InputVideoMaxSeconds + } + plan := &types.TaskBillingPlan{ + Source: source, + RuleKey: ruleKey, + SchemaVersion: profile.SchemaVersion, + Mode: profile.Mode, + ConfigHash: hashH3BillingConfig(profile), + Currency: profile.Currency, + GroupRatio: groupRatio, + QuotaPerUnit: common.QuotaPerUnit, + Resolution: resolution, + RequestedOutputDurationSeconds: input.OutputDurationSeconds, + InputVideoCount: input.InputVideoCount, + InputAudioCount: input.InputAudioCount, + InputImageCount: input.InputImageCount, + Components: []types.TaskBillingPlanComponent{ + {Key: "output_video", Unit: "second", UnitPrice: profile.OutputUnitPrice[resolution], RequestedQuantity: input.OutputDurationSeconds, ReservedQuantity: input.OutputDurationSeconds}, + {Key: "input_video", Unit: "second", UnitPrice: profile.InputVideoUnitPrice[resolution], ReservedQuantity: videoReserve}, + {Key: "input_image", Unit: "image", UnitPrice: profile.InputImageExtraUnitPrice, RequestedQuantity: input.InputImageCount, ReservedQuantity: input.InputImageCount, FreeQuantity: profile.InputImageFreeCount}, + {Key: "input_audio", Unit: "second", UnitPrice: profile.InputAudioUnitPrice, ReservedQuantity: 0}, + }, + } + + estimate, err := quoteH3Plan(plan, types.TaskBillingPlanStageEstimate, input.OutputDurationSeconds, 0, 0, input.InputImageCount) + if err != nil { + return nil, err + } + reserve, err := quoteH3Plan(plan, types.TaskBillingPlanStageReserve, input.OutputDurationSeconds, videoReserve, 0, input.InputImageCount) + if err != nil { + return nil, err + } + plan.EstimateQuota = estimate.Quota + plan.ReserveQuota = reserve.Quota + return plan, nil +} + +func QuoteH3Estimate(plan *types.TaskBillingPlan) (*H3BillingQuote, error) { + if plan == nil { + return nil, fmt.Errorf("H3 billing plan is required") + } + return quoteH3Plan(plan, types.TaskBillingPlanStageEstimate, plan.RequestedOutputDurationSeconds, 0, 0, plan.InputImageCount) +} + +func QuoteH3Reserve(plan *types.TaskBillingPlan) (*H3BillingQuote, error) { + if plan == nil { + return nil, fmt.Errorf("H3 billing plan is required") + } + videoSeconds := int64(0) + if plan.InputVideoCount > 0 { + for _, component := range plan.Components { + if component.Key == "input_video" { + videoSeconds = component.ReservedQuantity + break + } + } + } + return quoteH3Plan(plan, types.TaskBillingPlanStageReserve, plan.RequestedOutputDurationSeconds, videoSeconds, 0, plan.InputImageCount) +} + +func QuoteH3Final(plan *types.TaskBillingPlan, usage *types.TaskUsage) (*H3BillingQuote, error) { + if plan == nil || usage == nil { + return nil, fmt.Errorf("H3 billing plan and usage are required") + } + if err := validateH3BillingPlan(plan); err != nil { + return nil, err + } + if usage.Completeness != types.TaskUsageCompletenessComplete { + return nil, fmt.Errorf("H3 usage is not automatically billable: %s", usage.Completeness) + } + outputMs, err := usageMilliseconds(usage.OutputDurationMs, "output_duration_ms", true, H3MinOutputDurationSeconds, H3MaxOutputDurationSeconds) + if err != nil { + return nil, err + } + if outputMs > plan.RequestedOutputDurationSeconds*1000 { + return nil, fmt.Errorf("H3 output usage exceeds the requested duration") + } + videoMs, err := usageMilliseconds(usage.InputVideoDurationMs, "input_video_duration_ms", plan.InputVideoCount > 0, 0, H3MaxInputMediaDurationSeconds) + if err != nil { + return nil, err + } + videoReserve := int64(0) + for _, component := range plan.Components { + if component.Key == "input_video" { + videoReserve = component.ReservedQuantity + break + } + } + if videoMs > videoReserve*1000 { + return nil, fmt.Errorf("H3 input video usage exceeds the reserved maximum") + } + if plan.InputVideoCount == 0 && videoMs > 0 { + return nil, fmt.Errorf("H3 provider reported input video usage without a request video") + } + audioMs, err := usageMilliseconds(usage.InputAudioDurationMs, "input_audio_duration_ms", plan.InputAudioCount > 0, 0, H3MaxInputMediaDurationSeconds) + if err != nil { + return nil, err + } + if plan.InputAudioCount == 0 && audioMs > 0 { + return nil, fmt.Errorf("H3 provider reported input audio usage without a request audio") + } + if usage.InputImageCount == nil { + return nil, fmt.Errorf("H3 input_image_count is missing") + } + if *usage.InputImageCount < 0 { + return nil, fmt.Errorf("H3 input_image_count cannot be negative") + } + imageCount := *usage.InputImageCount + if imageCount != plan.InputImageCount { + return nil, fmt.Errorf("H3 input_image_count does not match the request snapshot") + } + quote, err := quoteH3PlanMilliseconds(plan, types.TaskBillingPlanStageFinal, outputMs, videoMs, audioMs, imageCount) + if err != nil { + return nil, err + } + if quote.Quota > plan.ReserveQuota { + return nil, fmt.Errorf("H3 final quota exceeds reserved quota") + } + return quote, nil +} + +func quoteH3Plan(plan *types.TaskBillingPlan, stage string, outputSeconds, videoSeconds, audioSeconds, imageCount int64) (*H3BillingQuote, error) { + if outputSeconds > math.MaxInt64/1000 || videoSeconds > math.MaxInt64/1000 || audioSeconds > math.MaxInt64/1000 { + return nil, fmt.Errorf("H3 billing quantities are out of range") + } + return quoteH3PlanMilliseconds(plan, stage, outputSeconds*1000, videoSeconds*1000, audioSeconds*1000, imageCount) +} + +func quoteH3PlanMilliseconds(plan *types.TaskBillingPlan, stage string, outputMs, videoMs, audioMs, imageCount int64) (*H3BillingQuote, error) { + if err := validateH3BillingPlan(plan); err != nil { + return nil, err + } + if outputMs < 0 || videoMs < 0 || audioMs < 0 || imageCount < 0 { + return nil, fmt.Errorf("H3 billing quantities cannot be negative") + } + outputPrice, err := planComponentPrice(plan, "output_video") + if err != nil { + return nil, err + } + videoPrice, err := planComponentPrice(plan, "input_video") + if err != nil { + return nil, err + } + audioPrice, err := planComponentPrice(plan, "input_audio") + if err != nil { + return nil, err + } + imagePrice, err := planComponentPrice(plan, "input_image") + if err != nil { + return nil, err + } + imageFree := planComponentFreeQuantity(plan, "input_image") + imageBillable := imageCount - imageFree + if imageBillable < 0 { + imageBillable = 0 + } + + outputQuantity := decimal.NewFromInt(outputMs).Div(decimal.NewFromInt(1000)) + videoQuantity := decimal.NewFromInt(videoMs).Div(decimal.NewFromInt(1000)) + audioQuantity := decimal.NewFromInt(audioMs).Div(decimal.NewFromInt(1000)) + price := outputQuantity.Mul(outputPrice) + price = price.Add(videoQuantity.Mul(videoPrice)) + price = price.Add(audioQuantity.Mul(audioPrice)) + price = price.Add(decimal.NewFromInt(imageBillable).Mul(imagePrice)) + quotaDecimal := price.Mul(decimal.NewFromFloat(plan.QuotaPerUnit)).Mul(decimal.NewFromFloat(plan.GroupRatio)) + quota, err := common.QuotaFromDecimalStrict(quotaDecimal) + if err != nil { + return nil, err + } + return &H3BillingQuote{ + Stage: stage, + Price: price.String(), + Quota: quota, + OutputSeconds: outputMs / 1000, + InputVideoSeconds: videoMs / 1000, + InputAudioSeconds: audioMs / 1000, + OutputDurationMs: outputMs, + InputVideoDurationMs: videoMs, + InputAudioDurationMs: audioMs, + InputImageCount: imageCount, + Components: []H3BillingQuoteComponent{ + {Key: "output_video", Unit: "second", Quantity: outputMs / 1000, QuantityDecimal: outputQuantity.String(), UnitPrice: outputPrice.String(), Price: outputQuantity.Mul(outputPrice).String()}, + {Key: "input_video", Unit: "second", Quantity: videoMs / 1000, QuantityDecimal: videoQuantity.String(), UnitPrice: videoPrice.String(), Price: videoQuantity.Mul(videoPrice).String()}, + {Key: "input_audio", Unit: "second", Quantity: audioMs / 1000, QuantityDecimal: audioQuantity.String(), UnitPrice: audioPrice.String(), Price: audioQuantity.Mul(audioPrice).String()}, + {Key: "input_image", Unit: "image", Quantity: imageBillable, QuantityDecimal: decimal.NewFromInt(imageBillable).String(), UnitPrice: imagePrice.String(), Price: decimal.NewFromInt(imageBillable).Mul(imagePrice).String()}, + }, + }, nil +} + +func validateH3BillingPlan(plan *types.TaskBillingPlan) error { + if plan == nil { + return fmt.Errorf("H3 billing plan identity is invalid") + } + if plan.Source != H3BillingSource && plan.Source != LegacyH3BillingSource { + return fmt.Errorf("H3 billing plan source is invalid") + } + if strings.TrimSpace(plan.RuleKey) == "" { + return fmt.Errorf("H3 billing plan rule key is invalid") + } + if plan.SchemaVersion != 1 { + return fmt.Errorf("H3 billing plan schema version is invalid") + } + if plan.Mode != H3BillingMode { + return fmt.Errorf("H3 billing plan mode is invalid") + } + if strings.TrimSpace(plan.ConfigHash) == "" { + return fmt.Errorf("H3 billing plan config hash is invalid") + } + if plan.GroupRatio < 0 || math.IsNaN(plan.GroupRatio) || math.IsInf(plan.GroupRatio, 0) { + return fmt.Errorf("H3 billing plan group ratio is invalid") + } + if plan.QuotaPerUnit <= 0 || math.IsNaN(plan.QuotaPerUnit) || math.IsInf(plan.QuotaPerUnit, 0) { + return fmt.Errorf("H3 billing plan quota per unit is invalid") + } + if plan.Resolution != "768P" && plan.Resolution != "2K" { + return fmt.Errorf("H3 billing plan resolution is invalid") + } + if plan.RequestedOutputDurationSeconds < H3MinOutputDurationSeconds || plan.RequestedOutputDurationSeconds > H3MaxOutputDurationSeconds { + return fmt.Errorf("H3 billing plan requested output duration is invalid") + } + if plan.InputVideoCount < 0 { + return fmt.Errorf("H3 billing plan input video count is invalid") + } + if plan.InputAudioCount < 0 { + return fmt.Errorf("H3 billing plan input audio count is invalid") + } + if plan.InputImageCount < 0 { + return fmt.Errorf("H3 billing plan input image count is invalid") + } + seen := make(map[string]struct{}, len(plan.Components)) + allowed := map[string]struct{}{"output_video": {}, "input_video": {}, "input_image": {}, "input_audio": {}} + for _, component := range plan.Components { + if _, ok := allowed[component.Key]; !ok { + return fmt.Errorf("H3 billing plan has unsupported component %s", component.Key) + } + if _, ok := seen[component.Key]; ok { + return fmt.Errorf("H3 billing plan has duplicate component %s", component.Key) + } + seen[component.Key] = struct{}{} + if _, err := nonNegativePrice(component.UnitPrice); err != nil || component.RequestedQuantity < 0 || component.ReservedQuantity < 0 || component.FreeQuantity < 0 { + return fmt.Errorf("H3 billing plan component %s is invalid", component.Key) + } + if component.Key == "output_video" && (component.RequestedQuantity != plan.RequestedOutputDurationSeconds || component.ReservedQuantity < component.RequestedQuantity) { + return fmt.Errorf("H3 output component does not match the request snapshot") + } + if component.Key == "input_image" && (component.RequestedQuantity != plan.InputImageCount || component.ReservedQuantity < component.RequestedQuantity) { + return fmt.Errorf("H3 image component does not match the request snapshot") + } + if component.Key == "input_video" && (component.ReservedQuantity < component.RequestedQuantity || component.ReservedQuantity > H3MaxInputMediaDurationSeconds) { + return fmt.Errorf("H3 input video component reservation is invalid") + } + } + for _, key := range []string{"output_video", "input_video", "input_image", "input_audio"} { + if _, ok := seen[key]; !ok { + return fmt.Errorf("H3 billing plan is missing component %s", key) + } + } + return nil +} + +func usageMilliseconds(value *int64, field string, required bool, minimum, maximum int64) (int64, error) { + if value == nil { + if required { + return 0, fmt.Errorf("H3 %s is missing", field) + } + return 0, nil + } + if *value < 0 { + return 0, fmt.Errorf("H3 %s must be a non-negative millisecond value", field) + } + if *value < minimum*1000 || *value > maximum*1000 { + return 0, fmt.Errorf("H3 %s is outside the allowed range", field) + } + return *value, nil +} + +func planComponentPrice(plan *types.TaskBillingPlan, key string) (decimal.Decimal, error) { + for _, component := range plan.Components { + if component.Key == key { + return nonNegativePrice(component.UnitPrice) + } + } + return decimal.Zero, fmt.Errorf("H3 billing plan is missing component %s", key) +} + +func planComponentFreeQuantity(plan *types.TaskBillingPlan, key string) int64 { + for _, component := range plan.Components { + if component.Key == key { + return component.FreeQuantity + } + } + return 0 +} + +func hashH3BillingConfig(config H3BillingConfig) string { + data, err := common.Marshal(config) + if err != nil { + return "" + } + hash := sha256.Sum256(data) + return fmt.Sprintf("sha256:%x", hash) +} + +func cloneH3BillingConfig(config H3BillingConfig) H3BillingConfig { + config.OutputUnitPrice = cloneStringMap(config.OutputUnitPrice) + config.InputVideoUnitPrice = cloneStringMap(config.InputVideoUnitPrice) + return config +} diff --git a/setting/task_billing_setting/h3_test.go b/setting/task_billing_setting/h3_test.go new file mode 100644 index 00000000..861d6517 --- /dev/null +++ b/setting/task_billing_setting/h3_test.go @@ -0,0 +1,393 @@ +package task_billing_setting + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/setting/config" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/require" +) + +func withH3BillingProfiles(t *testing.T, profiles map[string]H3BillingConfig) { + t.Helper() + original := GetH3BillingProfilesCopy() + data, err := common.Marshal(profiles) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{ + "h3_profiles": string(data), + })) + t.Cleanup(func() { + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{ + "h3_profiles": mustMarshalH3Profiles(original), + })) + }) +} + +func mustMarshalH3Profiles(profiles map[string]H3BillingConfig) string { + data, err := common.Marshal(profiles) + if err != nil { + panic(err) + } + return string(data) +} + +func TestBuildH3BillingPlanUsesOneAggregateInputVideoCap(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 3, + }, 1) + require.NoError(t, err) + reserve, err := QuoteH3Reserve(plan) + require.NoError(t, err) + // 5 output seconds + one aggregate 15-second input-video cap. + require.EqualValues(t, 20, reserve.OutputSeconds+reserve.InputVideoSeconds) + require.EqualValues(t, 1600, reserve.Quota) +} + +func TestBuildH3BillingPlanReadsMinimaxRuleFromUnifiedRateCards(t *testing.T) { + withQuotaPerUnit(t, 1000) + original := GetRateCardsCopy() + profile := defaultH3BillingConfig() + profile.OutputUnitPrice["768P"] = "0.10" + data, err := common.Marshal(map[string]RateCard{ + MinimaxBillingRuleKey: { + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(profile), + }, + }) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + t.Cleanup(func() { + data, marshalErr := common.Marshal(original) + require.NoError(t, marshalErr) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + }) + + plan, err := BuildH3BillingPlanForModels(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1, "MiniMax-H3") + require.NoError(t, err) + require.Equal(t, H3BillingSource, plan.Source) + require.Equal(t, MinimaxBillingRuleKey, plan.RuleKey) + require.EqualValues(t, 500, plan.EstimateQuota) +} + +func TestBuildH3BillingPlanUsesUpdatedLegacyProfileWithoutDefaultMinimaxCard(t *testing.T) { + withQuotaPerUnit(t, 1000) + cards := GetRateCardsCopy() + _, hasMinimaxCard := cards[MinimaxBillingRuleKey] + require.False(t, hasMinimaxCard) + require.Contains(t, cards, "kling-v3") + + profiles := GetH3BillingProfilesCopy() + profile := profiles[H3BillingProfileKey] + profile.OutputUnitPrice["768P"] = "0.20" + profiles[H3BillingProfileKey] = profile + withH3BillingProfiles(t, profiles) + + plan, err := BuildH3BillingPlanForModels(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1, "MiniMax-H3") + require.NoError(t, err) + require.Equal(t, LegacyH3BillingSource, plan.Source) + require.Equal(t, H3BillingProfileKey, plan.RuleKey) + + estimate, err := QuoteH3Estimate(plan) + require.NoError(t, err) + reserve, err := QuoteH3Reserve(plan) + require.NoError(t, err) + require.EqualValues(t, 1000, estimate.Quota) + require.EqualValues(t, 1000, reserve.Quota) +} + +func TestBuildH3BillingPlanSelectsExactModelAliasRateCard(t *testing.T) { + withQuotaPerUnit(t, 1000) + original := GetRateCardsCopy() + profile := defaultH3BillingConfig() + profile.OutputUnitPrice["768P"] = "0.11" + data, err := common.Marshal(map[string]RateCard{ + "MiniMax-H3": { + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(profile), + }, + }) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + t.Cleanup(func() { + data, marshalErr := common.Marshal(original) + require.NoError(t, marshalErr) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + }) + + plan, err := BuildH3BillingPlanForModels(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1, "MiniMax-H3") + require.NoError(t, err) + require.Equal(t, H3BillingSource, plan.Source) + require.Equal(t, "MiniMax-H3", plan.RuleKey) + require.EqualValues(t, 550, plan.EstimateQuota) +} + +func TestBuildH3BillingPlanDoesNotChargeFreeImagesOrAudio(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "2K", OutputDurationSeconds: 5, InputImageCount: 5, InputAudioCount: 1, + }, 1) + require.NoError(t, err) + require.EqualValues(t, 650, plan.EstimateQuota) + require.EqualValues(t, 650, plan.ReserveQuota) + + usage := &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputAudioDurationMs: int64Ptr(6_000), + InputImageCount: int64Ptr(5), + Completeness: types.TaskUsageCompletenessComplete, + } + final, err := QuoteH3Final(plan, usage) + require.NoError(t, err) + require.EqualValues(t, 650, final.Quota) + require.EqualValues(t, 6, final.InputAudioSeconds) +} + +func TestQuoteH3FinalRejectsMissingOrConflictingUsage(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, + }, 1) + require.NoError(t, err) + + _, err = QuoteH3Final(plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + Completeness: types.TaskUsageCompletenessPartial, + }) + require.Error(t, err) + + _, err = QuoteH3Final(plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputVideoDurationMs: int64Ptr(0), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.ErrorContains(t, err, "input_image_count is missing") + + _, err = QuoteH3Final(plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputVideoDurationMs: int64Ptr(16_000), + InputImageCount: int64Ptr(0), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.Error(t, err) +} + +func TestQuoteH3FinalReportsInvalidPlanProperty(t *testing.T) { + withQuotaPerUnit(t, 1000) + basePlan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + + tests := []struct { + name string + mutate func(*types.TaskBillingPlan) + message string + }{ + { + name: "group ratio", + mutate: func(plan *types.TaskBillingPlan) { plan.GroupRatio = -1 }, + message: "group ratio is invalid", + }, + { + name: "quota per unit", + mutate: func(plan *types.TaskBillingPlan) { plan.QuotaPerUnit = 0 }, + message: "quota per unit is invalid", + }, + { + name: "resolution", + mutate: func(plan *types.TaskBillingPlan) { plan.Resolution = "1080P" }, + message: "resolution is invalid", + }, + { + name: "requested output duration", + mutate: func(plan *types.TaskBillingPlan) { plan.RequestedOutputDurationSeconds = 0 }, + message: "requested output duration is invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plan := *basePlan + tt.mutate(&plan) + _, err := QuoteH3Final(&plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputImageCount: int64Ptr(0), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.ErrorContains(t, err, tt.message) + }) + } +} + +func TestQuoteH3FinalSupportsFractionalMediaSeconds(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, InputAudioCount: 1, + }, 1) + require.NoError(t, err) + + final, err := QuoteH3Final(plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputVideoDurationMs: int64Ptr(7_500), + InputAudioDurationMs: int64Ptr(2_250), + InputImageCount: int64Ptr(0), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + require.EqualValues(t, 1000, final.Quota) + require.Equal(t, int64(7_500), final.InputVideoDurationMs) + require.Equal(t, "7.5", final.Components[1].QuantityDecimal) +} + +func TestQuoteH3FinalRejectsImageCountMismatch(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputImageCount: 2, + }, 1) + require.NoError(t, err) + + _, err = QuoteH3Final(plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputImageCount: int64Ptr(1), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.Error(t, err) +} + +func TestH3BillingConfigValidationRejectsIncompleteProfile(t *testing.T) { + raw := `{"minimax_h3_v2":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}` + require.Error(t, ValidateH3ProfilesJSON(raw)) +} + +func TestH3BillingConfigValidationRejectsEmptyProfiles(t *testing.T) { + require.Error(t, ValidateH3ProfilesJSON("")) + require.Error(t, ValidateH3ProfilesJSON(" ")) +} + +func TestH3BillingConfigValidationRejectsProviderLimitOverflow(t *testing.T) { + profiles := defaultH3BillingProfiles() + profile := profiles[H3BillingProfileKey] + profile.InputVideoMaxSeconds = 16 + profiles[H3BillingProfileKey] = profile + raw, err := common.Marshal(profiles) + require.NoError(t, err) + require.Error(t, ValidateH3ProfilesJSON(string(raw))) + + profile.InputVideoMaxSeconds = 15 + profile.InputImageFreeCount = 10 + profiles[H3BillingProfileKey] = profile + raw, err = common.Marshal(profiles) + require.NoError(t, err) + require.Error(t, ValidateH3ProfilesJSON(string(raw))) +} + +func TestH3BillingConfigValidationRejectsPaidAudioInSchemaV1(t *testing.T) { + profiles := defaultH3BillingProfiles() + profile := profiles[H3BillingProfileKey] + profile.InputAudioUnitPrice = "0.01" + profiles[H3BillingProfileKey] = profile + raw, err := common.Marshal(profiles) + require.NoError(t, err) + require.Error(t, ValidateH3ProfilesJSON(string(raw))) +} + +func TestH3BillingPlanKeepsFrozenPricesAfterConfigReload(t *testing.T) { + withQuotaPerUnit(t, 1000) + original := GetH3BillingProfilesCopy() + withH3BillingProfiles(t, original) + + oldPlan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + oldQuote, err := QuoteH3Reserve(oldPlan) + require.NoError(t, err) + oldQuotaPerUnit := common.QuotaPerUnit + common.QuotaPerUnit = 2000 + t.Cleanup(func() { common.QuotaPerUnit = oldQuotaPerUnit }) + + updated := GetH3BillingProfilesCopy() + profile := updated[H3BillingProfileKey] + profile.OutputUnitPrice["768P"] = "0.20" + updated[H3BillingProfileKey] = profile + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{ + "h3_profiles": mustMarshalH3Profiles(updated), + })) + + newPlan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + newQuote, err := QuoteH3Reserve(newPlan) + require.NoError(t, err) + require.EqualValues(t, 400, oldQuote.Quota) + require.EqualValues(t, 2000, newQuote.Quota) + require.NotEqual(t, oldPlan.ConfigHash, newPlan.ConfigHash) + + oldQuoteAgain, err := QuoteH3Reserve(oldPlan) + require.NoError(t, err) + require.Equal(t, oldQuote, oldQuoteAgain) +} + +func TestPreviewH3BillingUsesDraftWithoutChangingGlobalConfig(t *testing.T) { + withQuotaPerUnit(t, 1000) + original := GetH3BillingProfilesCopy() + draft := cloneH3BillingConfig(original[H3BillingProfileKey]) + draft.OutputUnitPrice["768P"] = "0.10" + draft.InputVideoUnitPrice["768P"] = "0.10" + + preview, err := PreviewH3Billing(draft, H3BillingInput{ + Resolution: "768P", + OutputDurationSeconds: 5, + InputVideoCount: 1, + }, 1, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputVideoDurationMs: int64Ptr(7_500), + InputImageCount: int64Ptr(0), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + require.Equal(t, "0.5", preview.Estimate.Price) + require.Equal(t, "2", preview.Reserve.Price) + require.Equal(t, "1.25", preview.Final.Price) + require.Equal(t, -750, *preview.AdjustmentQuota) + require.Equal(t, 750, *preview.RefundQuota) + require.NotEqual(t, hashH3BillingConfig(original[H3BillingProfileKey]), preview.ConfigHash) + require.Equal(t, original, GetH3BillingProfilesCopy()) +} + +func TestPreviewH3BillingRejectsInvalidDraftAndRequestBounds(t *testing.T) { + profile, ok := GetH3BillingProfileCopy(H3BillingProfileKey) + require.True(t, ok) + + invalidProfile := cloneH3BillingConfig(*profile) + invalidProfile.InputAudioUnitPrice = "0.01" + _, err := PreviewH3Billing(invalidProfile, H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1, nil) + require.Error(t, err) + + _, err = PreviewH3Billing(*profile, H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputImageCount: H3MaxInputImageCount + 1, + }, 1, nil) + require.Error(t, err) + + _, err = PreviewH3Billing(*profile, H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: H3MaxInputVideoCount + 1, + }, 1, nil) + require.Error(t, err) +} + +func int64Ptr(value int64) *int64 { + return &value +} diff --git a/setting/task_billing_setting/rate_card.go b/setting/task_billing_setting/rate_card.go index 862a6c71..bf2963d0 100644 --- a/setting/task_billing_setting/rate_card.go +++ b/setting/task_billing_setting/rate_card.go @@ -1,6 +1,8 @@ package task_billing_setting import ( + "bytes" + "encoding/json" "fmt" "math" "path" @@ -9,6 +11,7 @@ import ( "strings" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/setting/config" "github.com/MAX-API-Next/MAX-API/types" ) @@ -19,22 +22,67 @@ type RateCardRow struct { UnitPrice float64 `json:"unit_price"` } +const RateCardBillingTypeMinimax = "minimax" + type RateCard struct { Vendor string `json:"vendor,omitempty"` + BillingType string `json:"billing_type,omitempty"` + BillingConfig map[string]any `json:"billing_config,omitempty"` Unit string `json:"unit,omitempty"` QuantityField string `json:"quantity_field,omitempty"` DefaultQuantity float64 `json:"default_quantity,omitempty"` - Strict bool `json:"strict"` + Strict bool `json:"strict,omitempty"` Defaults map[string]string `json:"defaults,omitempty"` Rows []RateCardRow `json:"rows"` + legacyFields rateCardLegacyFields +} + +type rateCardLegacyFields struct { + unit bool + quantityField bool + defaultQuantity bool + strict bool + defaults bool + rows bool +} + +// UnmarshalJSON records whether legacy fields were explicitly present so a +// structured card cannot silently accept zero-valued fields that it ignores. +func (card *RateCard) UnmarshalJSON(data []byte) error { + type rateCardAlias RateCard + var decoded rateCardAlias + if err := common.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := common.Unmarshal(data, &fields); err != nil { + return err + } + decoded.legacyFields = rateCardLegacyFields{ + unit: hasConfiguredJSONField(fields, "unit"), + quantityField: hasConfiguredJSONField(fields, "quantity_field"), + defaultQuantity: hasConfiguredJSONField(fields, "default_quantity"), + strict: hasConfiguredJSONField(fields, "strict"), + defaults: hasConfiguredJSONField(fields, "defaults"), + rows: hasConfiguredJSONField(fields, "rows"), + } + *card = RateCard(decoded) + return nil +} + +func hasConfiguredJSONField(fields map[string]json.RawMessage, key string) bool { + value, ok := fields[key] + return ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) } type TaskBillingSetting struct { - RateCards *types.RWMap[string, RateCard] `json:"rate_cards"` + RateCards *types.RWMap[string, RateCard] `json:"rate_cards"` + H3Profiles *types.RWMap[string, H3BillingConfig] `json:"h3_profiles"` } var taskBillingSetting = TaskBillingSetting{ - RateCards: newRateCardMap(defaultRateCards()), + RateCards: newRateCardMap(defaultRateCards()), + H3Profiles: newH3BillingProfileMap(defaultH3BillingProfiles()), } func init() { @@ -59,7 +107,10 @@ func GetRateCardCopy(models ...string) (*RateCard, string) { func HasRateCard(models ...string) bool { card, _ := findRateCard(models...) - return card != nil + // This signal is consumed by legacy per-call pricing and the generic + // single-quantity task calculator. Structured cards have their own planner + // and must not turn a missing model price into a zero-price fallback. + return card != nil && card.BillingType == "" } func Calculate(input types.TaskBillingInput, groupRatio float64) (*types.TaskBillingResult, error) { @@ -67,6 +118,12 @@ func Calculate(input types.TaskBillingInput, groupRatio float64) (*types.TaskBil if card == nil { return nil, nil } + // Structured provider rules are executed by their task planner after the + // adaptor has normalized provider-specific facts. They are not reducible to + // the single-quantity legacy rate-card calculator. + if card.BillingType != "" { + return nil, nil + } fields := mergeFields(*card, input) quantity, err := resolveQuantity(*card, input, fields) if err != nil { @@ -120,6 +177,35 @@ func validateRateCards(rateCards map[string]RateCard) error { if strings.TrimSpace(key) == "" { return fmt.Errorf("rate card key cannot be empty") } + if card.BillingType != "" { + if card.BillingType != RateCardBillingTypeMinimax { + return fmt.Errorf("rate card %s has unsupported billing_type %q", key, card.BillingType) + } + if !isMinimaxH3RateCardKey(key) { + return fmt.Errorf("rate card %s structured billing only supports MiniMax-H3", key) + } + if len(card.BillingConfig) == 0 { + return fmt.Errorf("rate card %s billing_config is required", key) + } + if card.legacyFields.rows || len(card.Rows) > 0 { + return fmt.Errorf("rate card %s structured billing cannot define rows", key) + } + if card.legacyFields.unit || card.legacyFields.quantityField || card.legacyFields.defaultQuantity || card.legacyFields.strict || card.legacyFields.defaults || + strings.TrimSpace(card.Unit) != "" || strings.TrimSpace(card.QuantityField) != "" || card.DefaultQuantity != 0 || card.Strict || len(card.Defaults) > 0 { + return fmt.Errorf("rate card %s structured billing cannot define legacy fields", key) + } + billingConfig, err := decodeMinimaxBillingConfig(card.BillingConfig) + if err != nil { + return fmt.Errorf("rate card %s: %w", key, err) + } + if err := validateH3BillingConfig(key, billingConfig); err != nil { + return err + } + continue + } + if len(card.BillingConfig) > 0 { + return fmt.Errorf("rate card %s billing_config requires billing_type", key) + } if len(card.Rows) == 0 { return fmt.Errorf("rate card %s has no rows", key) } @@ -135,6 +221,10 @@ func validateRateCards(rateCards map[string]RateCard) error { return nil } +func isMinimaxH3RateCardKey(key string) bool { + return key == MinimaxBillingRuleKey || key == constant.TaskModelMiniMaxH3 +} + func validQuantity(value float64) bool { return value > 0 && value <= math.MaxInt32 && !math.IsNaN(value) && !math.IsInf(value, 0) } @@ -150,6 +240,9 @@ func findRateCard(models ...string) (*RateCard, string) { continue } if card, ok := rateCards[model]; ok { + if card.BillingType != "" && !isMinimaxH3RateCardKey(model) { + continue + } return &card, model } } @@ -169,10 +262,42 @@ func findRateCard(models ...string) (*RateCard, string) { } if matchPattern(key, model) { card := rateCards[key] + if card.BillingType != "" { + continue + } return &card, key } } } + // MiniMax-H3 uses a structured rule whose canonical rate-card key is not + // the upstream model name. Keep this alias narrow so another MiniMax task + // model cannot accidentally inherit the H3 pricing rule. + for _, model := range models { + if isMinimaxH3Model(model) { + return findMinimaxRateCard(models...) + } + } + return nil, "" +} + +func findMinimaxRateCard(models ...string) (*RateCard, string) { + if taskBillingSetting.RateCards == nil { + return nil, "" + } + rateCards := taskBillingSetting.RateCards.ReadAll() + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" { + continue + } + if card, ok := rateCards[model]; ok && card.BillingType == MinimaxBillingType && isMinimaxH3RateCardKey(model) { + return &card, model + } + } + + if card, ok := rateCards[MinimaxBillingRuleKey]; ok && card.BillingType == MinimaxBillingType { + return &card, MinimaxBillingRuleKey + } return nil, "" } @@ -309,7 +434,11 @@ func cloneRateCards(src map[string]RateCard) map[string]RateCard { } func cloneRateCard(card RateCard) RateCard { + card.BillingConfig = cloneBillingConfig(card.BillingConfig) card.Defaults = cloneStringMap(card.Defaults) + if card.Rows == nil { + return card + } rows := make([]RateCardRow, len(card.Rows)) for i, row := range card.Rows { row.Match = cloneStringMap(row.Match) @@ -319,6 +448,21 @@ func cloneRateCard(card RateCard) RateCard { return card } +func cloneBillingConfig(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + data, err := common.Marshal(src) + if err != nil { + return src + } + var dst map[string]any + if err := common.Unmarshal(data, &dst); err != nil { + return src + } + return dst +} + func cloneStringMap(src map[string]string) map[string]string { if len(src) == 0 { return nil diff --git a/setting/task_billing_setting/rate_card_test.go b/setting/task_billing_setting/rate_card_test.go index 3d4f27aa..3fd12cf7 100644 --- a/setting/task_billing_setting/rate_card_test.go +++ b/setting/task_billing_setting/rate_card_test.go @@ -1,6 +1,7 @@ package task_billing_setting import ( + "fmt" "strings" "sync" "testing" @@ -79,6 +80,166 @@ func TestCalculateUnknownModelFallsBack(t *testing.T) { } } +func TestValidateRateCardsAcceptsStructuredMinimaxRule(t *testing.T) { + raw := `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}` + require.NoError(t, ValidateRateCardsJSON(raw)) +} + +func TestGetRateCardsCopyPreservesStructuredRowsRoundTrip(t *testing.T) { + original := GetRateCardsCopy() + raw := `{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}` + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": raw})) + t.Cleanup(func() { + data, err := common.Marshal(original) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + }) + + copied := GetRateCardsCopy() + card, ok := copied[MinimaxBillingRuleKey] + require.True(t, ok) + require.Nil(t, card.Rows) + + data, err := common.Marshal(copied) + require.NoError(t, err) + require.Contains(t, string(data), `"rows":null`) + require.NoError(t, ValidateRateCardsJSON(string(data))) +} + +func TestValidateRateCardsRejectsLegacyFieldsOnStructuredMinimaxRule(t *testing.T) { + legacyFields := map[string]func(*RateCard){ + "unit": func(card *RateCard) { card.Unit = "second" }, + "quantity_field": func(card *RateCard) { card.QuantityField = "duration" }, + "default_quantity": func(card *RateCard) { card.DefaultQuantity = 1 }, + "strict": func(card *RateCard) { card.Strict = true }, + "defaults": func(card *RateCard) { card.Defaults = map[string]string{"quality": "high"} }, + "rows": func(card *RateCard) { + card.Rows = []RateCardRow{{Match: map[string]string{"quality": "high"}, UnitPrice: 1}} + }, + } + + for name, mutate := range legacyFields { + t.Run(name, func(t *testing.T) { + card := RateCard{ + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(defaultH3BillingConfig()), + } + mutate(&card) + data, err := common.Marshal(map[string]RateCard{MinimaxBillingRuleKey: card}) + require.NoError(t, err) + require.Error(t, ValidateRateCardsJSON(string(data))) + }) + } +} + +func TestValidateRateCardsRejectsExplicitZeroLegacyFieldsOnStructuredMinimaxRule(t *testing.T) { + legacyFields := []string{ + `"unit":""`, + `"quantity_field":""`, + `"default_quantity":0`, + `"strict":false`, + `"defaults":{}`, + `"rows":[]`, + } + for _, field := range legacyFields { + raw := fmt.Sprintf(`{"minimax/minimax-h3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"},%s}}`, field) + require.ErrorContains(t, ValidateRateCardsJSON(raw), "structured billing cannot define") + } +} + +func TestValidateRateCardsRequiresCanonicalStructuredMinimaxKey(t *testing.T) { + structured := RateCard{ + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(defaultH3BillingConfig()), + } + + for _, key := range []string{MinimaxBillingRuleKey, "MiniMax-H3"} { + data, err := common.Marshal(map[string]RateCard{key: structured}) + require.NoError(t, err) + require.NoError(t, ValidateRateCardsJSON(string(data)), key) + } + + for _, key := range []string{"minimax-h3", " MINIMAX/MINIMAX-H3 ", " MiniMax-H3 "} { + data, err := common.Marshal(map[string]RateCard{key: structured}) + require.NoError(t, err) + require.ErrorContains(t, ValidateRateCardsJSON(string(data)), "only supports MiniMax-H3", key) + } +} + +func TestValidateRateCardsRejectsStructuredNonH3Model(t *testing.T) { + raw := `{"MiniMax-Hailuo-2.3":{"vendor":"minimax","billing_type":"minimax","billing_config":{"schema_version":1,"mode":"bounded_actual","currency":"USD","output_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_unit_price":{"768P":"0.08","2K":"0.13"},"input_video_max_seconds":15,"input_image_free_count":5,"input_image_extra_unit_price":"0.04","input_audio_unit_price":"0"}}}` + require.ErrorContains(t, ValidateRateCardsJSON(raw), "only supports MiniMax-H3") +} + +func TestHasRateCardIgnoresStructuredNonH3ExactKey(t *testing.T) { + original := GetRateCardsCopy() + data, err := common.Marshal(map[string]RateCard{ + "MiniMax-Hailuo-2.3": { + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(defaultH3BillingConfig()), + }, + }) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + t.Cleanup(func() { + data, marshalErr := common.Marshal(original) + require.NoError(t, marshalErr) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + }) + + require.False(t, HasRateCard("MiniMax-Hailuo-2.3")) +} + +func TestCalculateSkipsStructuredMinimaxRule(t *testing.T) { + withQuotaPerUnit(t, 1000) + + original := GetRateCardsCopy() + structured := RateCard{ + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(defaultH3BillingConfig()), + } + data, err := common.Marshal(map[string]RateCard{"minimax/minimax-h3": structured}) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + t.Cleanup(func() { + data, marshalErr := common.Marshal(original) + require.NoError(t, marshalErr) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + }) + + got, err := Calculate(types.TaskBillingInput{Model: "minimax/minimax-h3"}, 1) + require.NoError(t, err) + require.Nil(t, got) + require.False(t, HasRateCard("MiniMax-H3")) +} + +func TestHasRateCardIgnoresStructuredWildcardKey(t *testing.T) { + original := GetRateCardsCopy() + data, err := common.Marshal(map[string]RateCard{ + "*": { + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(defaultH3BillingConfig()), + }, + }) + require.NoError(t, err) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + t.Cleanup(func() { + data, marshalErr := common.Marshal(original) + require.NoError(t, marshalErr) + require.NoError(t, config.UpdateConfigFromMap(&taskBillingSetting, map[string]string{"rate_cards": string(data)})) + }) + + // This bypasses the config validation boundary to guard against stale or + // programmatically loaded maps reintroducing structured wildcard cards. + require.False(t, HasRateCard("MiniMax-H3")) + require.False(t, HasRateCard("MiniMax-Hailuo-2.3")) +} + func TestRateCardsSupportConcurrentReloads(t *testing.T) { setting := TaskBillingSetting{RateCards: newRateCardMap(nil)} raw := `{"model":{"rows":[{"match":{"quality":"std"},"unit_price":1}]}}` diff --git a/types/task_billing_plan.go b/types/task_billing_plan.go new file mode 100644 index 00000000..3126c4d0 --- /dev/null +++ b/types/task_billing_plan.go @@ -0,0 +1,50 @@ +package types + +const ( + TaskBillingPlanModeBoundedActual = "bounded_actual" + TaskBillingPlanStageEstimate = "estimate" + TaskBillingPlanStageReserve = "reserve" + TaskBillingPlanStageFinal = "final" +) + +// TaskBillingPlan is an immutable, provider-neutral snapshot of the pricing +// facts selected for one asynchronous task. It is intentionally limited to +// registered components; it is not an executable expression or plugin. +type TaskBillingPlan struct { + Source string `json:"source"` + RuleKey string `json:"rule_key"` + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + ConfigHash string `json:"config_hash"` + Currency string `json:"currency"` + GroupRatio float64 `json:"group_ratio"` + QuotaPerUnit float64 `json:"quota_per_unit"` + Resolution string `json:"resolution"` + + RequestedOutputDurationSeconds int64 `json:"requested_output_duration_seconds"` + InputVideoCount int64 `json:"input_video_count"` + InputAudioCount int64 `json:"input_audio_count"` + InputImageCount int64 `json:"input_image_count"` + + Components []TaskBillingPlanComponent `json:"components"` + EstimateQuota int `json:"estimate_quota"` + ReserveQuota int `json:"reserve_quota"` +} + +type TaskBillingPlanComponent struct { + Key string `json:"key"` + Unit string `json:"unit"` + UnitPrice string `json:"unit_price"` + RequestedQuantity int64 `json:"requested_quantity"` + ReservedQuantity int64 `json:"reserved_quantity"` + FreeQuantity int64 `json:"free_quantity"` +} + +func CloneTaskBillingPlan(plan *TaskBillingPlan) *TaskBillingPlan { + if plan == nil { + return nil + } + cloned := *plan + cloned.Components = append([]TaskBillingPlanComponent(nil), plan.Components...) + return &cloned +} diff --git a/types/task_usage.go b/types/task_usage.go new file mode 100644 index 00000000..b29786cb --- /dev/null +++ b/types/task_usage.go @@ -0,0 +1,30 @@ +package types + +const ( + TaskUsageSourceRequestEstimate = "request_estimate" + TaskUsageSourceProviderResponse = "provider_response" + TaskUsageSourceCallback = "callback" + TaskUsageSourceManualReconcile = "manual_reconcile" +) + +const ( + TaskUsageCompletenessComplete = "complete" + TaskUsageCompletenessPartial = "partial" + TaskUsageCompletenessMissing = "missing" + TaskUsageCompletenessInvalid = "invalid" + TaskUsageCompletenessAmbiguous = "ambiguous" +) + +// TaskUsage is the provider-neutral usage fact used by asynchronous task +// billing. Pointer fields preserve the difference between an omitted field and +// an explicit zero returned by the provider. +type TaskUsage struct { + OutputDurationMs *int64 `json:"output_duration_ms,omitempty"` + InputVideoDurationMs *int64 `json:"input_video_duration_ms,omitempty"` + InputAudioDurationMs *int64 `json:"input_audio_duration_ms,omitempty"` + InputImageCount *int64 `json:"input_image_count,omitempty"` + InputVideoCount *int64 `json:"input_video_count,omitempty"` + InputAudioCount *int64 `json:"input_audio_count,omitempty"` + Source string `json:"source,omitempty"` + Completeness string `json:"completeness,omitempty"` +} diff --git a/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx b/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx new file mode 100644 index 00000000..e9f08a19 --- /dev/null +++ b/web/default/src/features/system-settings/models/task-rate-card-settings.runtime.test.tsx @@ -0,0 +1,138 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createReactTestEnvironment } from '@/test/react' +import { within } from '@testing-library/react' +import assert from 'node:assert/strict' +import { after, before, describe, test } from 'node:test' +import { TaskRateCardSettings } from './task-rate-card-settings' + +const testEnv = createReactTestEnvironment() + +before(() => testEnv.setup()) + +after(() => testEnv.teardown()) + +describe('TaskRateCardSettings billing examples', () => { + test('keeps the MiniMax structured example preview-only', async () => { + const queryClient = new QueryClient() + const view = await testEnv.render( + + + + ) + + try { + const screen = within(view.container) + const minimaxHeading = screen.getByRole('heading', { + name: 'MiniMax billing example', + }) + const minimaxSection = minimaxHeading.closest('section') + assert.ok(minimaxSection) + + const minimaxExample = within(minimaxSection) + const useButton = minimaxExample.getByRole('button', { + name: 'Use example', + }) as HTMLButtonElement + assert.equal(useButton.disabled, true) + minimaxExample.getByText( + 'Preview only: structured MiniMax billing is not yet used for task admission or settlement. Configure a normal model price separately; requests without one are rejected.' + ) + + const exampleJson = minimaxExample.getByRole( + 'textbox' + ) as HTMLTextAreaElement + assert.match(exampleJson.value, /"billing_type": "minimax"/) + + const copyButton = minimaxExample.getByRole('button', { + name: 'Copy to clipboard', + }) as HTMLButtonElement + assert.equal(copyButton.disabled, false) + + const klingHeading = screen.getByRole('heading', { + name: 'Kling billing example', + }) + const klingSection = klingHeading.closest('section') + assert.ok(klingSection) + const klingUseButton = within(klingSection).getByRole('button', { + name: 'Use example', + }) as HTMLButtonElement + assert.equal(klingUseButton.disabled, false) + } finally { + await view.unmount() + queryClient.clear() + } + }) + + test('does not re-render billing examples when rate-card text changes', async () => { + const queryClient = new QueryClient() + const originalTranslate = testEnv.i18n.t + const titleCalls = { + kling: 0, + minimax: 0, + } + + testEnv.i18n.t = new Proxy(originalTranslate, { + apply(target, thisArg, args) { + if (args[0] === 'Kling billing example') titleCalls.kling += 1 + if (args[0] === 'MiniMax billing example') titleCalls.minimax += 1 + return Reflect.apply(target, thisArg, args) + }, + }) + + try { + const view = await testEnv.render( + + + + ) + + try { + assert.ok(titleCalls.kling > 0) + assert.ok(titleCalls.minimax > 0) + titleCalls.kling = 0 + titleCalls.minimax = 0 + + const screen = within(view.container) + const klingHeading = screen.getByRole('heading', { + name: 'Kling billing example', + }) + const klingSection = klingHeading.closest('section') + assert.ok(klingSection) + const klingUseButton = within(klingSection).getByRole('button', { + name: 'Use example', + }) as HTMLButtonElement + await view.click(klingUseButton) + + const currentEditor = screen.getByRole('textbox', { + name: 'Current rate card JSON', + }) as HTMLTextAreaElement + assert.match(currentEditor.value, /kling\/kling-v3-video-generation/) + screen.getByText('kling/kling-v3-video-generation') + assert.equal(titleCalls.kling, 0) + assert.equal(titleCalls.minimax, 0) + } finally { + await view.unmount() + } + } finally { + testEnv.i18n.t = originalTranslate + queryClient.clear() + } + }) +}) diff --git a/web/default/src/features/system-settings/models/task-rate-card-settings.tsx b/web/default/src/features/system-settings/models/task-rate-card-settings.tsx index f22f7632..cf6d63d9 100644 --- a/web/default/src/features/system-settings/models/task-rate-card-settings.tsx +++ b/web/default/src/features/system-settings/models/task-rate-card-settings.tsx @@ -16,8 +16,16 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import { FileJson } from 'lucide-react' +import { + memo, + type ReactElement, + useCallback, + useEffect, + useId, + useMemo, + useState, +} from 'react' +import { AlertTriangle, FileJson } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -32,13 +40,21 @@ const OPTION_KEY = 'task_billing_setting.rate_cards' const VENDOR_LABELS: Record = { kling: 'Kling', + minimax: 'MiniMax', openai: 'OpenAI / Sora', google: 'Google / Veo', bytedance: 'ByteDance / Seedance', unclassified: 'Unclassified', } -const VENDOR_ORDER = ['kling', 'openai', 'google', 'bytedance', 'unclassified'] +const VENDOR_ORDER = [ + 'kling', + 'minimax', + 'openai', + 'google', + 'bytedance', + 'unclassified', +] const KLING_RATE_CARD_EXAMPLE = JSON.stringify( { @@ -108,6 +124,34 @@ const KLING_RATE_CARD_EXAMPLE = JSON.stringify( 2 ) +const MINIMAX_RATE_CARD_EXAMPLE = JSON.stringify( + { + 'minimax/minimax-h3': { + vendor: 'minimax', + billing_type: 'minimax', + billing_config: { + schema_version: 1, + mode: 'bounded_actual', + currency: 'USD', + output_unit_price: { + '768P': '0.08', + '2K': '0.13', + }, + input_video_unit_price: { + '768P': '0.08', + '2K': '0.13', + }, + input_video_max_seconds: 15, + input_image_free_count: 5, + input_image_extra_unit_price: '0.04', + input_audio_unit_price: '0', + }, + }, + }, + null, + 2 +) + type TaskRateCardSettingsProps = { defaultValue: string } @@ -120,6 +164,77 @@ type VendorSummary = { models: string[] } +type BillingExampleSectionProps = { + title: string + description: string + value: string + rows: number + onUseExample: () => void + notice?: string + useExampleDisabled?: boolean +} + +const BillingExampleSection = memo(function BillingExampleSection( + props: BillingExampleSectionProps +): ReactElement { + const { t } = useTranslation() + const headingId = useId() + const noticeId = useId() + + return ( +
+
+
+

+ {t(props.title)} +

+

+ {t(props.description)} +

+
+
+ + {t('Copy example')} + + +
+
+ {props.notice && ( + + + )} +