From 254d3151d8b6d1c89602041faf20603ffd6c1a04 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 5 Sep 2026 19:15:26 +0800 Subject: [PATCH 01/24] update --- controller/option.go | 9 + model/option.go | 4 + model/option_test.go | 1 + model/task.go | 1 + relay/channel/adapter.go | 13 + relay/channel/task/hailuo/adaptor.go | 168 ++++- relay/channel/task/hailuo/adaptor_test.go | 394 ++++++++++++ relay/channel/task/hailuo/constants.go | 21 +- relay/channel/task/hailuo/h3.go | 718 ++++++++++++++++++++++ relay/channel/task/hailuo/models.go | 72 +++ relay/common/relay_info.go | 26 +- relay/common/relay_utils.go | 17 +- relay/relay_task.go | 12 + relay/relay_task_test.go | 74 +++ setting/task_billing_setting/h3.go | 512 +++++++++++++++ setting/task_billing_setting/h3_test.go | 204 ++++++ setting/task_billing_setting/rate_card.go | 6 +- types/task_billing_plan.go | 50 ++ types/task_usage.go | 30 + 19 files changed, 2309 insertions(+), 23 deletions(-) create mode 100644 relay/channel/task/hailuo/h3.go create mode 100644 setting/task_billing_setting/h3.go create mode 100644 setting/task_billing_setting/h3_test.go create mode 100644 types/task_billing_plan.go create mode 100644 types/task_usage.go diff --git a/controller/option.go b/controller/option.go index dd2bee96..754d3aab 100644 --- a/controller/option.go +++ b/controller/option.go @@ -294,6 +294,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/model/option.go b/model/option.go index 80f13b4c..50c4a431 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 } @@ -819,6 +821,8 @@ func handleConfigUpdate(key, value string) (bool, error) { } else if configName == "task_billing_setting" { if configKey == "rate_cards" { _ = task_billing_setting.ValidateRateCardsJSON(value) + } else if configKey == "h3_profiles" { + _ = task_billing_setting.ValidateH3ProfilesJSON(value) } InvalidatePricingCache() } else if configName == "theme" { diff --git a/model/option_test.go b/model/option_test.go index e8a11944..9477b6d8 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")) 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..c2b324fc 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,50 @@ 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 + } + return task_billing_setting.BuildH3BillingPlan(input, groupRatio) +} + 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 +300,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 +353,53 @@ 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": + openAIVideo.Status = dto.VideoStatusCompleted + 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..65734f89 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -1,16 +1,410 @@ 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/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/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{ + 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 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, &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}) + require.Nil(t, taskErr) +} + +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 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) + + _, err := (&TaskAdaptor{}).FetchTask(server.URL, "test-key", map[string]any{ + "model": H3Model, + "task_id": "task/with-special-id", + }, "") + require.NoError(t, err) + + 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": 0 + } + } + }`)) + require.NoError(t, err) + require.Equal(t, types.TaskUsageCompletenessInvalid, result.Usage.Completeness) +} + +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: "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..53c35c41 100644 --- a/relay/channel/task/hailuo/constants.go +++ b/relay/channel/task/hailuo/constants.go @@ -5,6 +5,7 @@ const ( ) var ModelList = []string{ + "MiniMax-H3", "MiniMax-Hailuo-2.3", "MiniMax-Hailuo-2.3-Fast", "MiniMax-Hailuo-02", @@ -17,8 +18,12 @@ var ModelList = []string{ } const ( - TextToVideoEndpoint = "/v1/video_generation" - QueryTaskEndpoint = "/v1/query/video_generation" + TextToVideoEndpoint = "/v1/video_generation" + QueryTaskEndpoint = "/v1/query/video_generation" + H3Model = "MiniMax-H3" + H3MaxModel = "MiniMax-H3-Max" + H3TextToVideoEndpoint = "/v2/video_generation" + H3QueryTaskEndpoint = "/v2/query/video_generation" ) const ( @@ -44,9 +49,17 @@ const ( Resolution720P = "720P" Resolution768P = "768P" Resolution1080P = "1080P" + Resolution2K = "2K" ) const ( - DefaultDuration = 6 - DefaultResolution = Resolution720P + DefaultDuration = 6 + DefaultResolution = Resolution720P + H3DefaultDuration = 5 + H3MinDuration = 4 + H3MaxDuration = 15 + H3MaxInputVideoSeconds = 15 + H3MaxImages = 9 + H3MaxVideos = 3 + H3MaxAudios = 3 ) diff --git a/relay/channel/task/hailuo/h3.go b/relay/channel/task/hailuo/h3.go new file mode 100644 index 00000000..6a717d54 --- /dev/null +++ b/relay/channel/task/hailuo/h3.go @@ -0,0 +1,718 @@ +package hailuo + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/MAX-API-Next/MAX-API/common" + 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), H3Model) +} + +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 referenceImages > H3MaxImages || referenceVideos > H3MaxVideos || 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(firstFrame) == 0 && len(lastFrame) == 0 && 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)}}) + } + } + if len(firstFrame) == 0 && len(lastFrame) == 0 { + 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 == "" { + value = strings.TrimSpace(metadataString(req.Metadata, "resolution")) + } + 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) { + duration := H3DefaultDuration + if req.Duration != nil { + duration = *req.Duration + } else if strings.TrimSpace(req.Seconds) != "" { + parsed, err := strconv.Atoi(strings.TrimSpace(req.Seconds)) + if err != nil { + return 0, fmt.Errorf("H3 duration must be an integer") + } + duration = parsed + } + if duration < H3MinDuration || 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) { + value := strings.TrimSpace(metadataString(req.Metadata, "ratio")) + 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 { + if metadata == nil { + return "" + } + value, ok := metadata[key] + if !ok { + return "" + } + stringValue, _ := value.(string) + return stringValue +} + +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 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 := req.CallbackURL + if callbackURL == nil { + if value, ok := req.Metadata["callback_url"].(string); ok { + callbackURL = &value + } + } + 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: modelTaskFailure, 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 = modelTaskQueued + result.Progress = "30%" + case "running": + result.Status = modelTaskInProgress + result.Progress = "50%" + case "succeeded": + result.Progress = "100%" + if task.Content == nil || strings.TrimSpace(task.Content.URL) == "" { + result.Status = modelTaskInProgress + result.Progress = "90%" + } else { + result.Status = modelTaskSuccess + result.Url = strings.TrimSpace(task.Content.URL) + } + case "failed", "cancelled": + result.Status = modelTaskFailure + 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 + 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 + modelTaskQueued = "QUEUED" + modelTaskInProgress = "IN_PROGRESS" + modelTaskSuccess = "SUCCESS" + modelTaskFailure = "FAILURE" +) 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..4a47c995 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. @@ -921,15 +922,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_utils.go b/relay/common/relay_utils.go index 1cd1cd08..cf78837e 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -112,6 +112,17 @@ 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 +} + // MaxTaskDurationSeconds caps user-supplied video duration before it becomes // an OtherRatio billing multiplier. const MaxTaskDurationSeconds = 3600 @@ -267,8 +278,10 @@ 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 { - return taskErr + if strings.TrimSpace(req.Prompt) == "" && !hasTaskContentText(req.Content) { + if taskErr := validatePrompt(req.Prompt); taskErr != nil { + return taskErr + } } if taskErr := validateTaskDurationBounds(req); taskErr != nil { diff --git a/relay/relay_task.go b/relay/relay_task.go index 8a6502bb..2ba48742 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), @@ -303,6 +304,17 @@ 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. + info.TaskBillingPlan = nil + if planner, ok := adaptor.(channel.TaskBillingPlanProvider); ok { + plan, planErr := planner.BuildTaskBillingPlan(c, info) + if planErr != nil { + return nil, service.TaskErrorWrapper(planErr, "task_billing_plan_error", http.StatusBadRequest) + } + info.TaskBillingPlan = plan + } + // 6. Prefer a parameterized rate card when the adaptor can normalize the // request. Legacy task models continue to use OtherRatios. taskBillingOverride := false diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index c3ccdad5..ef456864 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,36 @@ 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) + + 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 +197,49 @@ 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 TestPrepareTaskSubmitRequestBodyMakesMultipartParamOverrideVisibleToBilling(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/setting/task_billing_setting/h3.go b/setting/task_billing_setting/h3.go new file mode 100644 index 00000000..76a0f70c --- /dev/null +++ b/setting/task_billing_setting/h3.go @@ -0,0 +1,512 @@ +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/types" + "github.com/shopspring/decimal" +) + +const ( + H3BillingSource = "minimax_hailuo" + H3BillingProfileKey = "minimax_h3_v2" + H3BillingMode = types.TaskBillingPlanModeBoundedActual +) + +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"` +} + +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: 15, + InputImageFreeCount: 5, + InputImageExtraUnitPrice: "0.04", + InputAudioUnitPrice: "0", + }, + } +} + +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 nil + } + 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 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 > 15 { + return fmt.Errorf("H3 billing profile %s input_video_max_seconds must be between 1 and 15", key) + } + if profile.InputImageFreeCount < 0 || profile.InputImageFreeCount > 9 { + return fmt.Errorf("H3 billing profile %s input_image_free_count must be between 0 and 9", key) + } + 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) + } + } + } + if _, ok := profiles[H3BillingProfileKey]; !ok { + return fmt.Errorf("H3 billing profile %s is required", H3BillingProfileKey) + } + 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) + } + if err := validateH3Profiles(map[string]H3BillingConfig{H3BillingProfileKey: *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 <= 0 || input.InputVideoCount < 0 || input.InputAudioCount < 0 || input.InputImageCount < 0 { + return nil, fmt.Errorf("H3 billing request facts are invalid") + } + if math.IsNaN(groupRatio) || math.IsInf(groupRatio, 0) || groupRatio < 0 || math.IsNaN(common.QuotaPerUnit) || math.IsInf(common.QuotaPerUnit, 0) || common.QuotaPerUnit <= 0 { + return nil, fmt.Errorf("H3 group ratio must be finite and non-negative") + } + + videoReserve := int64(0) + if input.InputVideoCount > 0 { + videoReserve = profile.InputVideoMaxSeconds + } + plan := &types.TaskBillingPlan{ + Source: H3BillingSource, + RuleKey: H3BillingProfileKey, + 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, 4, 15) + 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, 15) + 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, 15) + 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") + } + imageCount := plan.InputImageCount + if usage.InputImageCount != nil { + if *usage.InputImageCount < 0 { + return nil, fmt.Errorf("H3 input_image_count cannot be negative") + } + imageCount = *usage.InputImageCount + } else if plan.InputImageCount > 0 { + return nil, fmt.Errorf("H3 input_image_count is missing") + } + 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 || plan.Source != H3BillingSource || plan.RuleKey != H3BillingProfileKey || plan.SchemaVersion != 1 || plan.Mode != H3BillingMode || strings.TrimSpace(plan.ConfigHash) == "" { + return fmt.Errorf("H3 billing plan identity is invalid") + } + if plan.GroupRatio < 0 || math.IsNaN(plan.GroupRatio) || math.IsInf(plan.GroupRatio, 0) || plan.QuotaPerUnit <= 0 || math.IsNaN(plan.QuotaPerUnit) || math.IsInf(plan.QuotaPerUnit, 0) { + return fmt.Errorf("H3 billing plan group ratio is invalid") + } + if plan.Resolution != "768P" && plan.Resolution != "2K" || plan.RequestedOutputDurationSeconds < 4 || plan.RequestedOutputDurationSeconds > 15 { + return fmt.Errorf("H3 billing plan request facts are invalid") + } + if plan.InputVideoCount < 0 || plan.InputAudioCount < 0 || plan.InputImageCount < 0 { + return fmt.Errorf("H3 billing plan input counts are 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 > 15) { + 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 = cloneFloatMap(config.OutputUnitPrice) + config.InputVideoUnitPrice = cloneFloatMap(config.InputVideoUnitPrice) + return config +} + +func cloneFloatMap(source map[string]string) map[string]string { + if len(source) == 0 { + return nil + } + result := make(map[string]string, len(source)) + for key, value := range source { + result[key] = value + } + return result +} diff --git a/setting/task_billing_setting/h3_test.go b/setting/task_billing_setting/h3_test.go new file mode 100644 index 00000000..3f0b198a --- /dev/null +++ b/setting/task_billing_setting/h3_test.go @@ -0,0 +1,204 @@ +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 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), + InputImageCount: int64Ptr(0), + }) + require.Error(t, err) + + _, err = QuoteH3Final(plan, &types.TaskUsage{ + OutputDurationMs: int64Ptr(5_000), + InputVideoDurationMs: int64Ptr(16_000), + InputImageCount: int64Ptr(0), + Completeness: types.TaskUsageCompletenessComplete, + }) + require.Error(t, err) +} + +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 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 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..adc6c155 100644 --- a/setting/task_billing_setting/rate_card.go +++ b/setting/task_billing_setting/rate_card.go @@ -30,11 +30,13 @@ type RateCard struct { } 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() { 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"` +} From 5b5f1a1c47ce7ab58dd9cb9596cf32c1c93cf470 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 5 Sep 2026 20:07:13 +0800 Subject: [PATCH 02/24] fix H3 task validation and usage regressions --- relay/channel/task/hailuo/adaptor_test.go | 11 ++++-- relay/channel/task/hailuo/h3.go | 4 +- relay/common/relay_utils.go | 37 +++++++++++++++-- relay/common/relay_utils_test.go | 48 +++++++++++++++++++++++ setting/task_billing_setting/h3_test.go | 2 +- 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/relay/channel/task/hailuo/adaptor_test.go b/relay/channel/task/hailuo/adaptor_test.go index 65734f89..3ea76ff1 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -23,6 +24,7 @@ func newH3TestInfo() *relaycommon.RelayInfo { OriginModelName: H3Model, TaskRelayInfo: &relaycommon.TaskRelayInfo{}, ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeMiniMax, ChannelBaseUrl: H3TestBaseURL, UpstreamModelName: H3Model, }, @@ -87,7 +89,7 @@ func TestValidateH3RequestAcceptsTextOnlyInsideContent(t *testing.T) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = request - taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}) + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, newH3TestInfo()) require.Nil(t, taskErr) } @@ -186,11 +188,13 @@ func TestH3QueryUsesV2PathAndEscapesTaskID(t *testing.T) { })) t.Cleanup(server.Close) - _, err := (&TaskAdaptor{}).FetchTask(server.URL, "test-key", map[string]any{ + 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: @@ -318,12 +322,13 @@ func TestParseH3UsageMarksInvalidProviderValues(t *testing.T) { "total_seconds": 5, "input_seconds": 0, "output_seconds": "not-a-number", - "input_image_count": 0 + "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) { diff --git a/relay/channel/task/hailuo/h3.go b/relay/channel/task/hailuo/h3.go index 6a717d54..f52f0be3 100644 --- a/relay/channel/task/hailuo/h3.go +++ b/relay/channel/task/hailuo/h3.go @@ -581,7 +581,9 @@ func normalizeH3Usage(usage *H3Usage) *types.TaskUsage { } if imagesPresent { invalid = invalid || images < 0 || images > H3MaxImages - result.InputImageCount = int64Pointer(images) + if imagesErr == nil { + result.InputImageCount = int64Pointer(images) + } } if audioPresent { invalid = invalid || audio.IsNegative() || audio.GreaterThan(decimal.NewFromInt(H3MaxInputVideoSeconds)) diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index cf78837e..dfb965d8 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -123,6 +123,35 @@ func hasTaskContentText(content []map[string]any) bool { 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, "MiniMax-H3") + default: + return false + } +} + // MaxTaskDurationSeconds caps user-supplied video duration before it becomes // an OtherRatio billing multiplier. const MaxTaskDurationSeconds = 3600 @@ -278,10 +307,10 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d return createTaskError(err, "invalid_request", http.StatusBadRequest, true) } - if strings.TrimSpace(req.Prompt) == "" && !hasTaskContentText(req.Content) { - if taskErr := validatePrompt(req.Prompt); taskErr != nil { - return taskErr - } + 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 } if taskErr := validateTaskDurationBounds(req); taskErr != nil { diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go index 7db18dee..eef6608f 100644 --- a/relay/common/relay_utils_test.go +++ b/relay/common/relay_utils_test.go @@ -168,3 +168,51 @@ 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 + wantErr bool + }{ + {name: "H3 content", channelType: constant.ChannelTypeMiniMax, upstreamModel: "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) { + 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 + 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/setting/task_billing_setting/h3_test.go b/setting/task_billing_setting/h3_test.go index 3f0b198a..831dd19c 100644 --- a/setting/task_billing_setting/h3_test.go +++ b/setting/task_billing_setting/h3_test.go @@ -130,7 +130,7 @@ func TestQuoteH3FinalRejectsImageCountMismatch(t *testing.T) { } 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}}` + 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)) } From f81ca8a27a0fb74ff82e8f32b511abf8caf0dd76 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 6 Sep 2026 00:41:47 +0800 Subject: [PATCH 03/24] update --- controller/option.go | 60 ++ controller/option_test.go | 74 ++ router/api-router.go | 1 + router/api_router_test.go | 13 + setting/task_billing_setting/h3.go | 75 +- setting/task_billing_setting/h3_test.go | 54 ++ .../drawers/model-mutate-drawer.tsx | 1 + .../src/features/system-settings/api.ts | 34 + .../billing/h3-billing-settings.tsx | 734 ++++++++++++++++++ .../billing/h3-billing-utils.test.ts | 93 +++ .../billing/h3-billing-utils.ts | 251 ++++++ .../system-settings/billing/index.tsx | 1 + .../billing/section-registry.tsx | 11 + .../features/system-settings/models/index.tsx | 1 + .../src/features/system-settings/types.ts | 2 + web/default/src/i18n/locales/en.json | 51 +- web/default/src/i18n/locales/fr.json | 51 +- web/default/src/i18n/locales/ja.json | 51 +- web/default/src/i18n/locales/ru.json | 51 +- web/default/src/i18n/locales/vi.json | 51 +- web/default/src/i18n/locales/zh.json | 51 +- 21 files changed, 1700 insertions(+), 11 deletions(-) create mode 100644 web/default/src/features/system-settings/billing/h3-billing-settings.tsx create mode 100644 web/default/src/features/system-settings/billing/h3-billing-utils.test.ts create mode 100644 web/default/src/features/system-settings/billing/h3-billing-utils.ts diff --git a/controller/option.go b/controller/option.go index 754d3aab..68e21d84 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,65 @@ 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 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 + } + + var usage *types.TaskUsage + if request.Actual != nil { + usage = &types.TaskUsage{ + OutputDurationMs: request.Actual.OutputDurationMs, + InputVideoDurationMs: request.Actual.InputVideoDurationMs, + InputAudioDurationMs: request.Actual.InputAudioDurationMs, + InputImageCount: request.Actual.InputImageCount, + Completeness: types.TaskUsageCompletenessComplete, + } + } + 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) diff --git a/controller/option_test.go b/controller/option_test.go index cf198a49..72160e35 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,75 @@ 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 testInt64Pointer(value int64) *int64 { + return &value +} 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 index 76a0f70c..016c9e60 100644 --- a/setting/task_billing_setting/h3.go +++ b/setting/task_billing_setting/h3.go @@ -15,6 +15,12 @@ const ( H3BillingSource = "minimax_hailuo" H3BillingProfileKey = "minimax_h3_v2" H3BillingMode = types.TaskBillingPlanModeBoundedActual + + H3MinOutputDurationSeconds int64 = 4 + H3MaxOutputDurationSeconds int64 = 15 + H3MaxInputVideoCount int64 = 3 + H3MaxInputAudioCount int64 = 3 + H3MaxInputImageCount int64 = 9 ) type H3BillingConfig struct { @@ -62,6 +68,17 @@ type H3BillingQuote struct { 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: { @@ -110,7 +127,7 @@ func GetH3BillingProfileCopy(key string) (*H3BillingConfig, bool) { func ValidateH3ProfilesJSON(raw string) error { if strings.TrimSpace(raw) == "" { - return nil + return fmt.Errorf("H3 billing profiles cannot be empty") } var profiles map[string]H3BillingConfig if err := common.UnmarshalJsonStr(raw, &profiles); err != nil { @@ -197,19 +214,67 @@ func BuildH3BillingPlan(input H3BillingInput, groupRatio float64) (*types.TaskBi if !ok { return nil, fmt.Errorf("H3 billing profile %s is not configured", H3BillingProfileKey) } - if err := validateH3Profiles(map[string]H3BillingConfig{H3BillingProfileKey: *profile}); err != nil { + return buildH3BillingPlan(*profile, input, groupRatio) +} + +func PreviewH3Billing(config H3BillingConfig, input H3BillingInput, groupRatio float64, usage *types.TaskUsage) (*H3BillingPreview, error) { + plan, err := buildH3BillingPlan(config, input, groupRatio) + 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) (*types.TaskBillingPlan, error) { + if err := validateH3Profiles(map[string]H3BillingConfig{H3BillingProfileKey: 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 <= 0 || input.InputVideoCount < 0 || input.InputAudioCount < 0 || input.InputImageCount < 0 { + 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 || math.IsNaN(common.QuotaPerUnit) || math.IsInf(common.QuotaPerUnit, 0) || common.QuotaPerUnit <= 0 { + 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 { @@ -220,7 +285,7 @@ func BuildH3BillingPlan(input H3BillingInput, groupRatio float64) (*types.TaskBi RuleKey: H3BillingProfileKey, SchemaVersion: profile.SchemaVersion, Mode: profile.Mode, - ConfigHash: hashH3BillingConfig(*profile), + ConfigHash: hashH3BillingConfig(profile), Currency: profile.Currency, GroupRatio: groupRatio, QuotaPerUnit: common.QuotaPerUnit, diff --git a/setting/task_billing_setting/h3_test.go b/setting/task_billing_setting/h3_test.go index 831dd19c..f864c2d8 100644 --- a/setting/task_billing_setting/h3_test.go +++ b/setting/task_billing_setting/h3_test.go @@ -134,6 +134,11 @@ func TestH3BillingConfigValidationRejectsIncompleteProfile(t *testing.T) { 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] @@ -199,6 +204,55 @@ func TestH3BillingPlanKeepsFrozenPricesAfterConfigReload(t *testing.T) { 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/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index fbd3aa0c..3d771423 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -196,6 +196,7 @@ export function ModelMutateDrawer({ 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'task_billing_setting.rate_cards': '{}', + 'task_billing_setting.h3_profiles': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/api.ts b/web/default/src/features/system-settings/api.ts index 2e078338..e09653a2 100644 --- a/web/default/src/features/system-settings/api.ts +++ b/web/default/src/features/system-settings/api.ts @@ -17,6 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { api } from '@/lib/api' +import type { + H3BillingPreview, + H3BillingPreviewScenario, + H3BillingProfile, +} from './billing/h3-billing-utils' import type { ConfirmPaymentComplianceResponse, FetchUpstreamRatiosRequest, @@ -40,6 +45,35 @@ export async function updateSystemOption(request: UpdateOptionRequest) { return res.data } +export async function previewH3Billing(request: { + profile: H3BillingProfile + scenario: H3BillingPreviewScenario + groupRatio: number +}) { + const res = await api.post<{ + success: boolean + message: string + data?: H3BillingPreview + }>('/api/option/h3_billing/preview', { + profile: request.profile, + resolution: request.scenario.resolution, + output_duration_seconds: request.scenario.outputDurationSeconds, + input_video_count: request.scenario.inputVideoCount, + input_audio_count: request.scenario.inputAudioCount, + input_image_count: request.scenario.inputImageCount, + group_ratio: request.groupRatio, + actual: request.scenario.actual + ? { + output_duration_ms: request.scenario.actual.outputDurationMs, + input_video_duration_ms: request.scenario.actual.inputVideoDurationMs, + input_audio_duration_ms: request.scenario.actual.inputAudioDurationMs, + input_image_count: request.scenario.actual.inputImageCount, + } + : undefined, + }) + return res.data +} + export async function updateTieredBillingConfig(request: { config: Record }) { diff --git a/web/default/src/features/system-settings/billing/h3-billing-settings.tsx b/web/default/src/features/system-settings/billing/h3-billing-settings.tsx new file mode 100644 index 00000000..8732c245 --- /dev/null +++ b/web/default/src/features/system-settings/billing/h3-billing-settings.tsx @@ -0,0 +1,734 @@ +/* +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 { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import * as z from 'zod' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { Separator } from '@/components/ui/separator' +import { previewH3Billing } from '../api' +import { + SettingsForm, + SettingsFormGrid, + SettingsFormGridItem, + SettingsControlGroup, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' +import { SettingsSection } from '../components/settings-section' +import { useResetForm } from '../hooks/use-reset-form' +import { useUpdateOption } from '../hooks/use-update-option' +import { safeNumberFieldProps } from '../utils/numeric-field' +import { + buildH3BillingFormValues, + buildH3BillingProfile, + DEFAULT_H3_BILLING_PROFILE, + H3_BILLING_PROFILE_KEY, + fingerprintH3BillingProfile, + hasLegacyH3RateCard, + parseH3BillingProfiles, + serializeH3BillingProfiles, + type H3BillingPreview, + type H3BillingPreviewScenario, + type H3BillingProfile, + type H3BillingProfiles, + type H3Resolution, +} from './h3-billing-utils' + +const createH3BillingSchema = (t: (key: string) => string) => { + const decimalString = z + .string() + .trim() + .min(1, t('Enter a non-negative decimal price.')) + .refine( + (value) => /^\d+(?:\.\d+)?$/.test(value), + t('Enter a non-negative decimal price.') + ) + + return z.object({ + output768Price: decimalString, + output2KPrice: decimalString, + inputVideo768Price: decimalString, + inputVideo2KPrice: decimalString, + inputVideoMaxSeconds: z.number().int().min(1).max(15), + inputImageFreeCount: z.number().int().min(0).max(9), + inputImageExtraPrice: decimalString, + }) +} + +type H3BillingFormValues = z.infer> + +function getObservedTimestamp() { + return Date.now() +} + +type H3BillingSettingsProps = { + defaultValue: string + legacyRateCards: string +} + +type PreviewScenarioState = { + resolution: H3Resolution + outputDurationSeconds: number + inputVideoCount: number + inputAudioCount: number + inputImageCount: number + includeActual: boolean + actualOutputDurationMs: number + actualInputVideoDurationMs: number + actualInputAudioDurationMs: number +} + +const DEFAULT_SCENARIO: PreviewScenarioState = { + resolution: '768P', + outputDurationSeconds: 5, + inputVideoCount: 0, + inputAudioCount: 0, + inputImageCount: 0, + includeActual: false, + actualOutputDurationMs: 5000, + actualInputVideoDurationMs: 0, + actualInputAudioDurationMs: 0, +} + +function buildDefaultValues(profile: H3BillingProfile): H3BillingFormValues { + return buildH3BillingFormValues(profile) +} + +function buildPreviewScenario( + state: PreviewScenarioState +): H3BillingPreviewScenario { + const scenario: H3BillingPreviewScenario = { + resolution: state.resolution, + outputDurationSeconds: state.outputDurationSeconds, + inputVideoCount: state.inputVideoCount, + inputAudioCount: state.inputAudioCount, + inputImageCount: state.inputImageCount, + } + if (state.includeActual) { + scenario.actual = { + outputDurationMs: state.actualOutputDurationMs, + inputImageCount: state.inputImageCount, + ...(state.inputVideoCount > 0 + ? { inputVideoDurationMs: state.actualInputVideoDurationMs } + : {}), + ...(state.inputAudioCount > 0 + ? { inputAudioDurationMs: state.actualInputAudioDurationMs } + : {}), + } + } + return scenario +} + +function PreviewSummary({ preview }: { preview: H3BillingPreview }) { + const { t } = useTranslation() + const adjustment = preview.adjustment_quota + return ( +
+
+ {[ + { label: t('Estimate'), quote: preview.estimate }, + { label: t('Reserve'), quote: preview.reserve }, + { label: t('Actual'), quote: preview.final }, + ].map((item) => ( +
+
{item.label}
+ {item.quote ? ( +
+ + {item.quote.price} + + + {item.quote.quota.toLocaleString()} {t('quota')} + +
+ ) : ( +
+ {t('Not included')} +
+ )} +
+ ))} +
+ {adjustment !== undefined && ( +

+ {adjustment < 0 + ? t('Estimated refund: {{count}} quota', { + count: preview.refund_quota ?? Math.abs(adjustment), + }) + : t('Additional quota required: {{count}}', { count: adjustment })} +

+ )} +

+ {t('Configuration hash')}: {preview.config_hash} +

+
+ ) +} + +export function H3BillingSettings(props: H3BillingSettingsProps) { + const { t, i18n } = useTranslation() + const updateOption = useUpdateOption() + const previewMutation = useMutation({ mutationFn: previewH3Billing }) + const h3BillingSchema = useMemo(() => createH3BillingSchema(t), [t]) + const parsed = useMemo( + () => parseH3BillingProfiles(props.defaultValue || '{}'), + [props.defaultValue] + ) + const profile = + parsed.profiles[H3_BILLING_PROFILE_KEY] ?? DEFAULT_H3_BILLING_PROFILE + const formDefaults = useMemo(() => buildDefaultValues(profile), [profile]) + const form = useForm({ + resolver: zodResolver(h3BillingSchema), + mode: 'onChange', + defaultValues: formDefaults, + }) + useResetForm(form, formDefaults) + + const [scenario, setScenario] = useState(DEFAULT_SCENARIO) + const [groupRatio, setGroupRatio] = useState(1) + const [preview, setPreview] = useState<{ + data: H3BillingPreview + fingerprint: string + } | null>(null) + const [previewError, setPreviewError] = useState('') + const [lastSaved, setLastSaved] = useState<{ + configHash: string + observedAt: number + persistedValue: string + } | null>(null) + const previewRequestId = useRef(0) + const sourceValueRef = useRef(props.defaultValue) + + const invalidatePreview = useCallback(() => { + previewRequestId.current += 1 + setPreview(null) + setPreviewError('') + }, []) + const updateScenario = useCallback( + ( + key: K, + value: PreviewScenarioState[K] + ) => { + setScenario((current) => ({ ...current, [key]: value })) + invalidatePreview() + }, + [invalidatePreview] + ) + + useEffect(() => { + if (sourceValueRef.current === props.defaultValue) return + sourceValueRef.current = props.defaultValue + invalidatePreview() + setLastSaved((current) => + current?.persistedValue === props.defaultValue ? current : null + ) + }, [props.defaultValue, invalidatePreview]) + + const profileError = Boolean(parsed.error) + const legacyRateCard = useMemo( + () => hasLegacyH3RateCard(props.legacyRateCards), + [props.legacyRateCards] + ) + + const getDraftProfile = useCallback( + (values: H3BillingFormValues) => buildH3BillingProfile(profile, values), + [profile] + ) + + const handlePreview = async () => { + const valid = await form.trigger() + if (!valid || profileError) return + setPreviewError('') + const values = form.getValues() + const draftProfile = getDraftProfile(values) + const requestId = ++previewRequestId.current + try { + const response = await previewMutation.mutateAsync({ + profile: draftProfile, + scenario: buildPreviewScenario(scenario), + groupRatio: + Number.isFinite(groupRatio) && groupRatio >= 0 ? groupRatio : 0, + }) + if (requestId !== previewRequestId.current) return + if (!response.success || !response.data) { + const message = response.message || t('H3 billing preview failed') + setPreviewError(message) + toast.error(message) + return + } + setPreview({ + data: response.data, + fingerprint: fingerprintH3BillingProfile(draftProfile), + }) + } catch (error) { + if (requestId !== previewRequestId.current) return + const message = + error instanceof Error ? error.message : t('H3 billing preview failed') + setPreviewError(message) + toast.error(message) + } + } + + const handleSave = async (values: H3BillingFormValues) => { + if (profileError) return + const draftProfile = getDraftProfile(values) + const fingerprint = fingerprintH3BillingProfile(draftProfile) + if (!preview || preview.fingerprint !== fingerprint) { + return + } + const profiles: H3BillingProfiles = Object.fromEntries( + Object.entries(parsed.profiles).map(([key, value]) => [key, { ...value }]) + ) + profiles[H3_BILLING_PROFILE_KEY] = draftProfile + const persistedValue = serializeH3BillingProfiles(profiles) + const response = await updateOption.mutateAsync({ + key: 'task_billing_setting.h3_profiles', + value: persistedValue, + }) + if (response.success) { + setLastSaved({ + configHash: preview.data.config_hash, + observedAt: getObservedTimestamp(), + persistedValue, + }) + setPreview(null) + } + } + + return ( + +
+ + {t('Structured H3 billing profile')} + + {t( + 'Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.' + )} + + + + {legacyRateCard && ( + + {t('Legacy rate card detected')} + + {t( + 'The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.' + )} + + + )} + + {profileError && ( + + {t('H3 profile is unavailable')} + + {t( + 'The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.' + )} + + + )} + +
+ + +
+
+

+ {t('Price per second')} +

+

+ {t( + 'Prices are entered in the configured currency and validated again by the server.' + )} +

+
+
+
{t('Model')}: MiniMax-H3
+
{t('Channel')}: 35
+
+
+ + {[ + ['output768Price', t('Output video / 768P')], + ['output2KPrice', t('Output video / 2K')], + ['inputVideo768Price', t('Input video / 768P')], + ['inputVideo2KPrice', t('Input video / 2K')], + ].map(([name, label]) => ( + ( + + {label} + + { + field.onChange(event) + invalidatePreview() + }} + /> + + {profile.currency} + + + )} + /> + ))} + +
+ + +

+ {t('Input limits and free allowance')} +

+ + { + const numeric = safeNumberFieldProps(field) + return ( + + + {t('Input video reservation cap')} + + + { + numeric.onChange(event) + invalidatePreview() + }} + /> + + + {t('1-15 seconds, shared across input videos')} + + + + ) + }} + /> + { + const numeric = safeNumberFieldProps(field) + return ( + + {t('Free input images')} + + { + numeric.onChange(event) + invalidatePreview() + }} + /> + + {t('0-9 images')} + + + ) + }} + /> + ( + + {t('Extra input image price')} + + { + field.onChange(event) + invalidatePreview() + }} + /> + + + {profile.currency} / image + + + + )} + /> + + {t('Input audio price')} + + + {t('Free in schema version 1')} + + + +
+ + +
+

+ {t('Server-side billing preview')} +

+

+ {t( + 'Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.' + )} +

+
+ + + + + + updateScenario('outputDurationSeconds', value) + } + /> + updateScenario('inputVideoCount', value)} + /> + updateScenario('inputAudioCount', value)} + /> + updateScenario('inputImageCount', value)} + /> +
+ + { + setGroupRatio(Number(event.target.value) || 0) + invalidatePreview() + }} + /> +
+
+ +
+ + +
+ + {scenario.includeActual && ( +
+ + updateScenario('actualOutputDurationMs', value) + } + /> + + updateScenario('actualInputVideoDurationMs', value) + } + /> + + updateScenario('actualInputAudioDurationMs', value) + } + /> +
+ )} + + {preview && ( + <> + + + + )} + {previewError && ( +

{previewError}

+ )} +
+ + {lastSaved && ( + + {t('Saved successfully')} + +
+ {t('Configuration hash')}: {lastSaved.configHash} +
+
+ {t('Last updated:')}{' '} + {new Date(lastSaved.observedAt).toLocaleString( + i18n.resolvedLanguage ?? i18n.language + )} +
+
+
+ )} + +
+ {t('Schema')}: 1 + {t('Mode')}: bounded_actual + + {t('Rule key')}: {H3_BILLING_PROFILE_KEY} + + + {t('Audio')}: {t('Free')} + +
+ + +
+
+
+
+ ) +} + +type PreviewNumberFieldProps = { + label: string + value: number + min: number + max: number + onChange: (value: number) => void +} + +function PreviewNumberField(props: PreviewNumberFieldProps) { + const id = useId() + return ( +
+ + props.onChange(Number(event.target.value) || 0)} + /> +
+ ) +} diff --git a/web/default/src/features/system-settings/billing/h3-billing-utils.test.ts b/web/default/src/features/system-settings/billing/h3-billing-utils.test.ts new file mode 100644 index 00000000..fa306517 --- /dev/null +++ b/web/default/src/features/system-settings/billing/h3-billing-utils.test.ts @@ -0,0 +1,93 @@ +/* +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 assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + buildH3BillingFormValues, + buildH3BillingProfile, + DEFAULT_H3_BILLING_PROFILE, + H3_BILLING_PROFILE_KEY, + hasLegacyH3RateCard, + parseH3BillingProfiles, + serializeH3BillingProfiles, +} from './h3-billing-utils' + +describe('H3 billing configuration utilities', () => { + test('parses the profile and preserves other structured profiles', () => { + const otherProfile = { + ...DEFAULT_H3_BILLING_PROFILE, + currency: 'CNY', + } + const raw = JSON.stringify({ + [H3_BILLING_PROFILE_KEY]: DEFAULT_H3_BILLING_PROFILE, + other_profile: otherProfile, + }) + + const result = parseH3BillingProfiles(raw) + + assert.equal(result.error, undefined) + assert.equal(result.profiles.other_profile.currency, 'CNY') + assert.deepEqual( + buildH3BillingFormValues(result.profiles[H3_BILLING_PROFILE_KEY]), + { + output768Price: '0.08', + output2KPrice: '0.13', + inputVideo768Price: '0.08', + inputVideo2KPrice: '0.13', + inputVideoMaxSeconds: 15, + inputImageFreeCount: 5, + inputImageExtraPrice: '0.04', + } + ) + }) + + test('rejects missing profile instead of allowing an empty save', () => { + const result = parseH3BillingProfiles('{}') + + assert.match(result.error || '', /minimax_h3_v2 is required/) + }) + + test('builds an editable profile without changing immutable audio pricing', () => { + const profile = buildH3BillingProfile(DEFAULT_H3_BILLING_PROFILE, { + output768Price: '0.10', + output2KPrice: '0.20', + inputVideo768Price: '0.11', + inputVideo2KPrice: '0.21', + inputVideoMaxSeconds: 10, + inputImageFreeCount: 2, + inputImageExtraPrice: '0.05', + }) + + assert.equal(profile.output_unit_price['768P'], '0.10') + assert.equal(profile.input_video_max_seconds, 10) + assert.equal(profile.input_audio_unit_price, '0') + assert.equal( + serializeH3BillingProfiles({ + [H3_BILLING_PROFILE_KEY]: profile, + }).includes('0.10'), + true + ) + }) + + test('detects only the legacy MiniMax-H3 rate-card key', () => { + assert.equal(hasLegacyH3RateCard('{"MiniMax-H3":{"unit":"second"}}'), true) + assert.equal(hasLegacyH3RateCard('{"minimax_h3_v2":{}}'), false) + assert.equal(hasLegacyH3RateCard('{invalid'), false) + }) +}) diff --git a/web/default/src/features/system-settings/billing/h3-billing-utils.ts b/web/default/src/features/system-settings/billing/h3-billing-utils.ts new file mode 100644 index 00000000..a5ccac5d --- /dev/null +++ b/web/default/src/features/system-settings/billing/h3-billing-utils.ts @@ -0,0 +1,251 @@ +/* +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 +*/ +export const H3_BILLING_PROFILE_KEY = 'minimax_h3_v2' + +export type H3Resolution = '768P' | '2K' + +export type H3BillingProfile = { + schema_version: number + mode: string + currency: string + output_unit_price: Record + input_video_unit_price: Record + input_video_max_seconds: number + input_image_free_count: number + input_image_extra_unit_price: string + input_audio_unit_price: string +} + +export type H3BillingProfiles = Record + +export type H3BillingFormValues = { + output768Price: string + output2KPrice: string + inputVideo768Price: string + inputVideo2KPrice: string + inputVideoMaxSeconds: number + inputImageFreeCount: number + inputImageExtraPrice: string +} + +export type H3BillingPreviewScenario = { + resolution: H3Resolution + outputDurationSeconds: number + inputVideoCount: number + inputAudioCount: number + inputImageCount: number + actual?: { + outputDurationMs: number + inputVideoDurationMs?: number + inputAudioDurationMs?: number + inputImageCount: number + } +} + +export type H3BillingQuoteComponent = { + key: string + unit: string + quantity: number + quantity_decimal?: string + unit_price: string + price: string +} + +export type H3BillingQuote = { + stage: string + price: string + quota: number + output_seconds: number + input_video_seconds: number + input_audio_seconds: number + output_duration_ms: number + input_video_duration_ms: number + input_audio_duration_ms: number + input_image_count: number + components: H3BillingQuoteComponent[] +} + +export type H3BillingPreview = { + config_hash: string + quota_per_unit: number + group_ratio: number + estimate: H3BillingQuote + reserve: H3BillingQuote + final?: H3BillingQuote + adjustment_quota?: number + refund_quota?: number +} + +export const DEFAULT_H3_BILLING_PROFILE: H3BillingProfile = { + 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', +} + +export const DEFAULT_H3_BILLING_PROFILES: H3BillingProfiles = { + [H3_BILLING_PROFILE_KEY]: DEFAULT_H3_BILLING_PROFILE, +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function isProfile(value: unknown): value is H3BillingProfile { + if (!isRecord(value)) return false + const output = value.output_unit_price + const inputVideo = value.input_video_unit_price + return ( + typeof value.schema_version === 'number' && + typeof value.mode === 'string' && + typeof value.currency === 'string' && + isRecord(output) && + typeof output['768P'] === 'string' && + typeof output['2K'] === 'string' && + isRecord(inputVideo) && + typeof inputVideo['768P'] === 'string' && + typeof inputVideo['2K'] === 'string' && + typeof value.input_video_max_seconds === 'number' && + typeof value.input_image_free_count === 'number' && + typeof value.input_image_extra_unit_price === 'string' && + typeof value.input_audio_unit_price === 'string' + ) +} + +export function parseH3BillingProfiles(value: string): { + profiles: H3BillingProfiles + error?: string +} { + if (!value.trim()) { + return { profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES) } + } + + try { + const parsed: unknown = JSON.parse(value) + if (!isRecord(parsed)) { + return { + profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), + error: 'H3 billing profiles must be a JSON object', + } + } + const profiles: H3BillingProfiles = {} + for (const [key, profile] of Object.entries(parsed)) { + if (!isProfile(profile)) { + return { + profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), + error: `H3 billing profile ${key} is incomplete`, + } + } + profiles[key] = cloneProfile(profile) + } + if (!profiles[H3_BILLING_PROFILE_KEY]) { + return { + profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), + error: `H3 billing profile ${H3_BILLING_PROFILE_KEY} is required`, + } + } + return { profiles } + } catch (error) { + return { + profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), + error: error instanceof Error ? error.message : 'Invalid H3 billing JSON', + } + } +} + +export function buildH3BillingFormValues( + profile: H3BillingProfile +): H3BillingFormValues { + return { + output768Price: profile.output_unit_price['768P'], + output2KPrice: profile.output_unit_price['2K'], + inputVideo768Price: profile.input_video_unit_price['768P'], + inputVideo2KPrice: profile.input_video_unit_price['2K'], + inputVideoMaxSeconds: profile.input_video_max_seconds, + inputImageFreeCount: profile.input_image_free_count, + inputImageExtraPrice: profile.input_image_extra_unit_price, + } +} + +export function buildH3BillingProfile( + current: H3BillingProfile, + values: H3BillingFormValues +): H3BillingProfile { + return { + ...cloneProfile(current), + output_unit_price: { + ...current.output_unit_price, + '768P': values.output768Price.trim(), + '2K': values.output2KPrice.trim(), + }, + input_video_unit_price: { + ...current.input_video_unit_price, + '768P': values.inputVideo768Price.trim(), + '2K': values.inputVideo2KPrice.trim(), + }, + input_video_max_seconds: values.inputVideoMaxSeconds, + input_image_free_count: values.inputImageFreeCount, + input_image_extra_unit_price: values.inputImageExtraPrice.trim(), + input_audio_unit_price: '0', + } +} + +export function serializeH3BillingProfiles(profiles: H3BillingProfiles) { + return JSON.stringify(profiles) +} + +export function fingerprintH3BillingProfile(profile: H3BillingProfile) { + return JSON.stringify(profile) +} + +export function hasLegacyH3RateCard(value: string) { + try { + const parsed: unknown = JSON.parse(value) + return ( + isRecord(parsed) && + Object.keys(parsed).some((key) => + key.toLowerCase().includes('minimax-h3') + ) + ) + } catch { + return false + } +} + +function cloneProfile(profile: H3BillingProfile): H3BillingProfile { + return { + ...profile, + output_unit_price: { ...profile.output_unit_price }, + input_video_unit_price: { ...profile.input_video_unit_price }, + } +} + +function cloneProfiles(profiles: H3BillingProfiles): H3BillingProfiles { + return Object.fromEntries( + Object.entries(profiles).map(([key, profile]) => [ + key, + cloneProfile(profile), + ]) + ) +} diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 7db296c0..1818c985 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -51,6 +51,7 @@ const defaultBillingSettings: BillingSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'task_billing_setting.rate_cards': '{}', + 'task_billing_setting.h3_profiles': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index d97ff3e1..8182e813 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -26,6 +26,7 @@ import { TaskRateCardSettings } from '../models/task-rate-card-settings' import { TieredBillingSettings } from '../models/tiered-billing-settings' import type { BillingSettings } from '../types' import { createSectionRegistry } from '../utils/section-registry' +import { H3BillingSettings } from './h3-billing-settings' const getModelDefaults = (settings: BillingSettings) => ({ ModelPrice: settings.ModelPrice, @@ -125,6 +126,16 @@ const BILLING_SECTIONS = [ /> ), }, + { + id: 'h3-billing', + titleKey: 'MiniMax-H3 Billing', + build: (settings: BillingSettings) => ( + + ), + }, { id: 'tiered-billing', titleKey: 'Tiered Billing', diff --git a/web/default/src/features/system-settings/models/index.tsx b/web/default/src/features/system-settings/models/index.tsx index 482877ad..8bf91ffd 100644 --- a/web/default/src/features/system-settings/models/index.tsx +++ b/web/default/src/features/system-settings/models/index.tsx @@ -55,6 +55,7 @@ const defaultModelSettings: ModelSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'task_billing_setting.rate_cards': '{}', + 'task_billing_setting.h3_profiles': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/types.ts b/web/default/src/features/system-settings/types.ts index bae6b2a3..d29ed6d0 100644 --- a/web/default/src/features/system-settings/types.ts +++ b/web/default/src/features/system-settings/types.ts @@ -219,6 +219,7 @@ export type ModelSettings = { 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string 'task_billing_setting.rate_cards': string + 'task_billing_setting.h3_profiles': string 'tool_price_setting.prices': string TopupGroupRatio: string GroupRatio: string @@ -265,6 +266,7 @@ export type BillingSettings = { 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string 'task_billing_setting.rate_cards': string + 'task_billing_setting.h3_profiles': string 'tool_price_setting.prices': string TopupGroupRatio: string GroupRatio: string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 6b71b8a4..5ddb12c6 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -5585,6 +5585,55 @@ "User blocked": "User blocked", "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.": "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.", "{{count}} / 1000 character": "{{count}} / 1000 character", - "{{count}} / 1000 characters": "{{count}} / 1000 characters" + "{{count}} / 1000 characters": "{{count}} / 1000 characters", + "Enter a non-negative decimal price.": "Enter a non-negative decimal price.", + "Estimate": "Estimate", + "Reserve": "Reserve", + "Actual": "Actual", + "quota": "quota", + "Not included": "Not included", + "Estimated refund: {{count}} quota": "Estimated refund: {{count}} quota", + "Additional quota required: {{count}}": "Additional quota required: {{count}}", + "Configuration hash": "Configuration hash", + "H3 billing preview failed": "H3 billing preview failed", + "MiniMax-H3 Billing": "MiniMax-H3 Billing", + "Structured H3 billing profile": "Structured H3 billing profile", + "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.": "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.", + "Legacy rate card detected": "Legacy rate card detected", + "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.": "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.", + "H3 profile is unavailable": "H3 profile is unavailable", + "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.": "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.", + "Price per second": "Price per second", + "Prices are entered in the configured currency and validated again by the server.": "Prices are entered in the configured currency and validated again by the server.", + "Output video / 768P": "Output video / 768P", + "Output video / 2K": "Output video / 2K", + "Input video / 768P": "Input video / 768P", + "Input video / 2K": "Input video / 2K", + "Input limits and free allowance": "Input limits and free allowance", + "Input video reservation cap": "Input video reservation cap", + "1-15 seconds, shared across input videos": "1-15 seconds, shared across input videos", + "Free input images": "Free input images", + "0-9 images": "0-9 images", + "Extra input image price": "Extra input image price", + "Input audio price": "Input audio price", + "Free in schema version 1": "Free in schema version 1", + "Server-side billing preview": "Server-side billing preview", + "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.": "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.", + "Resolution": "Resolution", + "Requested output seconds": "Requested output seconds", + "Input video count": "Input video count", + "Input audio count": "Input audio count", + "Input image count": "Input image count", + "Preview group ratio": "Preview group ratio", + "Include actual usage and refund preview": "Include actual usage and refund preview", + "Previewing...": "Previewing...", + "Preview billing": "Preview billing", + "Actual output milliseconds": "Actual output milliseconds", + "Actual input video milliseconds": "Actual input video milliseconds", + "Actual input audio milliseconds": "Actual input audio milliseconds", + "Schema": "Schema", + "Rule key": "Rule key", + "Free": "Free", + "Save H3 billing profile": "Save H3 billing profile" } } diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index b610c722..6d5e4d26 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -5585,6 +5585,55 @@ "User blocked": "Utilisateur bloqué", "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.": "Lorsque cette option est activée, les nouvelles requêtes payantes restent bloquées tant qu’au moins un enregistrement de règlement final positif non résolu bloque encore l’utilisateur. Autoriser un enregistrement examiné ne neutralise pas les autres enregistrements bloquants.", "{{count}} / 1000 character": "{{count}} / 1000 caractère", - "{{count}} / 1000 characters": "{{count}} / 1000 caractères" + "{{count}} / 1000 characters": "{{count}} / 1000 caractères", + "Enter a non-negative decimal price.": "Saisissez un prix décimal non négatif.", + "Estimate": "Estimation", + "Reserve": "Réservation", + "Actual": "Réel", + "quota": "quota", + "Not included": "Non inclus", + "Estimated refund: {{count}} quota": "Remboursement estimé : {{count}} quota", + "Additional quota required: {{count}}": "Quota supplémentaire requis : {{count}}", + "Configuration hash": "Empreinte de configuration", + "H3 billing preview failed": "L’aperçu de facturation H3 a échoué", + "MiniMax-H3 Billing": "Facturation MiniMax-H3", + "Structured H3 billing profile": "Profil de facturation H3 structuré", + "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.": "Configurez les prix à usage réel borné des nouvelles tâches MiniMax-H3. Les tâches existantes conservent leur instantané de facturation soumis.", + "Legacy rate card detected": "Ancienne grille tarifaire détectée", + "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.": "L’ancienne grille MiniMax-H3 est conservée pour la compatibilité ; elle ne correspond pas à ce profil H3 structuré.", + "H3 profile is unavailable": "Profil H3 indisponible", + "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.": "Le profil H3 enregistré est incomplet ou invalide. L’enregistrement est désactivé jusqu’à ce que le serveur renvoie un profil valide.", + "Price per second": "Prix par seconde", + "Prices are entered in the configured currency and validated again by the server.": "Les prix sont saisis dans la devise configurée puis vérifiés à nouveau par le serveur.", + "Output video / 768P": "Vidéo de sortie / 768P", + "Output video / 2K": "Vidéo de sortie / 2K", + "Input video / 768P": "Vidéo d’entrée / 768P", + "Input video / 2K": "Vidéo d’entrée / 2K", + "Input limits and free allowance": "Limites d’entrée et allocation gratuite", + "Input video reservation cap": "Plafond de réservation vidéo d’entrée", + "1-15 seconds, shared across input videos": "1 à 15 secondes, partagées entre les vidéos d’entrée", + "Free input images": "Images d’entrée gratuites", + "0-9 images": "0 à 9 images", + "Extra input image price": "Prix d’une image d’entrée supplémentaire", + "Input audio price": "Prix de l’audio d’entrée", + "Free in schema version 1": "Gratuit dans la version de schéma 1", + "Server-side billing preview": "Aperçu de facturation côté serveur", + "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.": "L’aperçu utilise le brouillon et ne crée aucun effet sur les tâches, le portefeuille, les tokens, les règlements, les journaux ou les statistiques.", + "Resolution": "Résolution", + "Requested output seconds": "Secondes de sortie demandées", + "Input video count": "Nombre de vidéos d’entrée", + "Input audio count": "Nombre d’audios d’entrée", + "Input image count": "Nombre d’images d’entrée", + "Preview group ratio": "Coefficient de groupe pour l’aperçu", + "Include actual usage and refund preview": "Inclure l’aperçu de l’usage réel et du remboursement", + "Previewing...": "Aperçu en cours...", + "Preview billing": "Prévisualiser la facturation", + "Actual output milliseconds": "Millisecondes de sortie réelles", + "Actual input video milliseconds": "Millisecondes vidéo d’entrée réelles", + "Actual input audio milliseconds": "Millisecondes audio d’entrée réelles", + "Schema": "Schéma", + "Rule key": "Clé de règle", + "Free": "Gratuit", + "Save H3 billing profile": "Enregistrer le profil de facturation H3" } } diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index e3daf6f5..e2ecd211 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -5585,6 +5585,55 @@ "User blocked": "ユーザーはブロック中", "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.": "有効にすると、未解決の正の最終精算レコードが1件でもユーザーをブロックしている間、新規有料リクエストは引き続きブロックされます。1件の確認済みレコードを許可しても、他のブロック対象レコードを上書きしません。", "{{count}} / 1000 character": "{{count}} / 1000 文字", - "{{count}} / 1000 characters": "{{count}} / 1000 文字" + "{{count}} / 1000 characters": "{{count}} / 1000 文字", + "Enter a non-negative decimal price.": "0 以上の小数価格を入力してください。", + "Estimate": "見積もり", + "Reserve": "予約", + "Actual": "実績", + "quota": "クォータ", + "Not included": "未含有", + "Estimated refund: {{count}} quota": "見込み返還: {{count}} クォータ", + "Additional quota required: {{count}}": "追加で必要なクォータ: {{count}}", + "Configuration hash": "設定ハッシュ", + "H3 billing preview failed": "H3 の課金プレビューに失敗しました", + "MiniMax-H3 Billing": "MiniMax-H3 課金", + "Structured H3 billing profile": "構造化 H3 課金プロファイル", + "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.": "新しい MiniMax-H3 タスクで使用する実績上限付き価格を設定します。既存のタスクは送信時の課金スナップショットを保持します。", + "Legacy rate card detected": "レガシー料金カードを検出しました", + "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.": "レガシー MiniMax-H3 料金カードは互換性のために保持されており、この構造化 H3 プロファイルではありません。", + "H3 profile is unavailable": "H3 プロファイルを利用できません", + "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.": "保存された H3 プロファイルが不完全または無効です。サーバーが有効なプロファイルを返すまで保存できません。", + "Price per second": "1 秒あたりの価格", + "Prices are entered in the configured currency and validated again by the server.": "価格は設定された通貨で入力し、サーバーでも再検証します。", + "Output video / 768P": "出力動画 / 768P", + "Output video / 2K": "出力動画 / 2K", + "Input video / 768P": "入力動画 / 768P", + "Input video / 2K": "入力動画 / 2K", + "Input limits and free allowance": "入力制限と無料枠", + "Input video reservation cap": "入力動画の予約上限", + "1-15 seconds, shared across input videos": "1~15 秒、入力動画全体で共有", + "Free input images": "無料入力画像", + "0-9 images": "0~9 枚", + "Extra input image price": "追加入力画像の価格", + "Input audio price": "入力音声の価格", + "Free in schema version 1": "スキーマバージョン 1 では無料", + "Server-side billing preview": "サーバー側の課金プレビュー", + "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.": "プレビューは下書きプロファイルを使用し、タスク、ウォレット、トークン、決済、ログ、統計への副作用はありません。", + "Resolution": "解像度", + "Requested output seconds": "要求する出力秒数", + "Input video count": "入力動画数", + "Input audio count": "入力音声数", + "Input image count": "入力画像数", + "Preview group ratio": "プレビュー用グループ倍率", + "Include actual usage and refund preview": "実績使用量と返還のプレビューを含める", + "Previewing...": "プレビュー中...", + "Preview billing": "課金をプレビュー", + "Actual output milliseconds": "実績出力ミリ秒", + "Actual input video milliseconds": "実績入力動画ミリ秒", + "Actual input audio milliseconds": "実績入力音声ミリ秒", + "Schema": "スキーマ", + "Rule key": "ルールキー", + "Free": "無料", + "Save H3 billing profile": "H3 課金プロファイルを保存" } } diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 5209cca7..e0c7887c 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -5585,6 +5585,55 @@ "User blocked": "Пользователь заблокирован", "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.": "Если параметр включён, новые платные запросы остаются заблокированными, пока хотя бы одна неразрешённая запись положительного окончательного расчёта продолжает блокировать пользователя. Разрешение по одной проверенной записи не отменяет другие блокирующие записи.", "{{count}} / 1000 character": "Символы: {{count}} / 1000", - "{{count}} / 1000 characters": "Символы: {{count}} / 1000" + "{{count}} / 1000 characters": "Символы: {{count}} / 1000", + "Enter a non-negative decimal price.": "Введите неотрицательную десятичную цену.", + "Estimate": "Оценка", + "Reserve": "Резерв", + "Actual": "Фактическое", + "quota": "квота", + "Not included": "Не включено", + "Estimated refund: {{count}} quota": "Расчётный возврат: {{count}} квоты", + "Additional quota required: {{count}}": "Требуется дополнительная квота: {{count}}", + "Configuration hash": "Хеш конфигурации", + "H3 billing preview failed": "Не удалось выполнить предпросмотр тарификации H3", + "MiniMax-H3 Billing": "Тарификация MiniMax-H3", + "Structured H3 billing profile": "Структурированный профиль тарификации H3", + "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.": "Настройте цены с ограничением по фактическому использованию для новых задач MiniMax-H3. Существующие задачи сохраняют снимок тарификации на момент отправки.", + "Legacy rate card detected": "Обнаружена устаревшая тарифная карта", + "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.": "Устаревшая тарифная карта MiniMax-H3 сохранена для совместимости и не является этим структурированным профилем H3.", + "H3 profile is unavailable": "Профиль H3 недоступен", + "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.": "Сохранённый профиль H3 неполон или недействителен. Сохранение отключено, пока сервер не вернёт корректный профиль.", + "Price per second": "Цена за секунду", + "Prices are entered in the configured currency and validated again by the server.": "Цены вводятся в настроенной валюте и повторно проверяются сервером.", + "Output video / 768P": "Выходное видео / 768P", + "Output video / 2K": "Выходное видео / 2K", + "Input video / 768P": "Входное видео / 768P", + "Input video / 2K": "Входное видео / 2K", + "Input limits and free allowance": "Ограничения входных данных и бесплатный объём", + "Input video reservation cap": "Лимит резерва входного видео", + "1-15 seconds, shared across input videos": "1–15 секунд, общие для входных видео", + "Free input images": "Бесплатные входные изображения", + "0-9 images": "0–9 изображений", + "Extra input image price": "Цена дополнительного входного изображения", + "Input audio price": "Цена входного аудио", + "Free in schema version 1": "Бесплатно в версии схемы 1", + "Server-side billing preview": "Предпросмотр тарификации на сервере", + "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.": "Предпросмотр использует черновой профиль и не создаёт побочных эффектов для задач, кошелька, токенов, расчётов, журналов или статистики.", + "Resolution": "Разрешение", + "Requested output seconds": "Запрошенные секунды вывода", + "Input video count": "Количество входных видео", + "Input audio count": "Количество входных аудио", + "Input image count": "Количество входных изображений", + "Preview group ratio": "Групповой коэффициент предпросмотра", + "Include actual usage and refund preview": "Включить предпросмотр фактического использования и возврата", + "Previewing...": "Предпросмотр...", + "Preview billing": "Предпросмотр тарификации", + "Actual output milliseconds": "Фактические миллисекунды вывода", + "Actual input video milliseconds": "Фактические миллисекунды входного видео", + "Actual input audio milliseconds": "Фактические миллисекунды входного аудио", + "Schema": "Схема", + "Rule key": "Ключ правила", + "Free": "Бесплатно", + "Save H3 billing profile": "Сохранить профиль тарификации H3" } } diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index c15d3b9a..227fb734 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -5585,6 +5585,55 @@ "User blocked": "Người dùng bị chặn", "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.": "Khi bật, các yêu cầu có tính phí mới vẫn bị chặn chừng nào còn bất kỳ bản ghi quyết toán cuối có số tiền dương chưa xử lý nào chặn người dùng. Việc cho phép một bản ghi đã kiểm tra không vô hiệu hóa các bản ghi chặn khác.", "{{count}} / 1000 character": "{{count}} / 1000 ký tự", - "{{count}} / 1000 characters": "{{count}} / 1000 ký tự" + "{{count}} / 1000 characters": "{{count}} / 1000 ký tự", + "Enter a non-negative decimal price.": "Nhập giá thập phân không âm.", + "Estimate": "Ước tính", + "Reserve": "Dự trữ", + "Actual": "Thực tế", + "quota": "hạn mức", + "Not included": "Không bao gồm", + "Estimated refund: {{count}} quota": "Hạn mức hoàn trả ước tính: {{count}}", + "Additional quota required: {{count}}": "Hạn mức bổ sung cần thiết: {{count}}", + "Configuration hash": "Mã băm cấu hình", + "H3 billing preview failed": "Xem trước tính phí H3 thất bại", + "MiniMax-H3 Billing": "Tính phí MiniMax-H3", + "Structured H3 billing profile": "Hồ sơ tính phí H3 có cấu trúc", + "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.": "Cấu hình giá theo mức sử dụng thực tế có giới hạn cho tác vụ MiniMax-H3 mới. Tác vụ hiện có giữ nguyên ảnh chụp cấu hình tính phí khi gửi.", + "Legacy rate card detected": "Đã phát hiện bảng giá cũ", + "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.": "Bảng giá MiniMax-H3 cũ được giữ để tương thích và không phải hồ sơ H3 có cấu trúc này.", + "H3 profile is unavailable": "Hồ sơ H3 không khả dụng", + "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.": "Hồ sơ H3 đã lưu không đầy đủ hoặc không hợp lệ. Không thể lưu cho đến khi máy chủ trả về hồ sơ hợp lệ.", + "Price per second": "Giá mỗi giây", + "Prices are entered in the configured currency and validated again by the server.": "Giá được nhập theo đơn vị tiền tệ đã cấu hình và được máy chủ kiểm tra lại.", + "Output video / 768P": "Video đầu ra / 768P", + "Output video / 2K": "Video đầu ra / 2K", + "Input video / 768P": "Video đầu vào / 768P", + "Input video / 2K": "Video đầu vào / 2K", + "Input limits and free allowance": "Giới hạn đầu vào và phần miễn phí", + "Input video reservation cap": "Giới hạn dự trữ video đầu vào", + "1-15 seconds, shared across input videos": "1-15 giây, dùng chung cho các video đầu vào", + "Free input images": "Ảnh đầu vào miễn phí", + "0-9 images": "0-9 ảnh", + "Extra input image price": "Giá ảnh đầu vào bổ sung", + "Input audio price": "Giá âm thanh đầu vào", + "Free in schema version 1": "Miễn phí trong phiên bản schema 1", + "Server-side billing preview": "Xem trước tính phí phía máy chủ", + "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.": "Bản xem trước dùng hồ sơ nháp và không tạo tác vụ hay tác động đến ví, token, quyết toán, nhật ký hoặc thống kê.", + "Resolution": "Độ phân giải", + "Requested output seconds": "Số giây đầu ra được yêu cầu", + "Input video count": "Số video đầu vào", + "Input audio count": "Số âm thanh đầu vào", + "Input image count": "Số ảnh đầu vào", + "Preview group ratio": "Tỷ lệ nhóm xem trước", + "Include actual usage and refund preview": "Bao gồm xem trước mức dùng thực tế và hoàn trả", + "Previewing...": "Đang xem trước...", + "Preview billing": "Xem trước tính phí", + "Actual output milliseconds": "Mili giây đầu ra thực tế", + "Actual input video milliseconds": "Mili giây video đầu vào thực tế", + "Actual input audio milliseconds": "Mili giây âm thanh đầu vào thực tế", + "Schema": "Schema", + "Rule key": "Khóa quy tắc", + "Free": "Miễn phí", + "Save H3 billing profile": "Lưu hồ sơ tính phí H3" } } diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 94ccab25..66265e8d 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -5585,6 +5585,55 @@ "User blocked": "用户已被阻止", "When enabled, new paid requests remain blocked while any unresolved positive final settlement record still blocks the user. Allowing one reviewed record does not override other blocking records.": "启用后,只要仍有任何未解决的正向最终结算记录阻止该用户,新的付费请求就会继续被阻止。允许某一条已核对记录不会覆盖其他阻塞记录。", "{{count}} / 1000 character": "{{count}} / 1000 个字符", - "{{count}} / 1000 characters": "{{count}} / 1000 个字符" + "{{count}} / 1000 characters": "{{count}} / 1000 个字符", + "Enter a non-negative decimal price.": "请输入非负十进制价格。", + "Estimate": "估算", + "Reserve": "预留", + "Actual": "实际", + "quota": "额度", + "Not included": "未包含", + "Estimated refund: {{count}} quota": "预计退还:{{count}} 额度", + "Additional quota required: {{count}}": "需要追加额度:{{count}}", + "Configuration hash": "配置哈希", + "H3 billing preview failed": "H3 计费预览失败", + "MiniMax-H3 Billing": "MiniMax-H3 计费", + "Structured H3 billing profile": "结构化 H3 计费配置", + "Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.": "配置新 MiniMax-H3 任务使用的有界实际用量价格。已有任务保留提交时的计费快照。", + "Legacy rate card detected": "检测到旧版费率卡", + "The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.": "旧版 MiniMax-H3 费率卡仅用于兼容,不是当前结构化 H3 配置。", + "H3 profile is unavailable": "H3 配置不可用", + "The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.": "已保存的 H3 配置不完整或无效。在服务端返回有效配置前无法保存。", + "Price per second": "每秒价格", + "Prices are entered in the configured currency and validated again by the server.": "价格使用配置的货币输入,并由服务端再次校验。", + "Output video / 768P": "输出视频 / 768P", + "Output video / 2K": "输出视频 / 2K", + "Input video / 768P": "输入视频 / 768P", + "Input video / 2K": "输入视频 / 2K", + "Input limits and free allowance": "输入限制与免费额度", + "Input video reservation cap": "输入视频预留上限", + "1-15 seconds, shared across input videos": "1-15 秒,所有输入视频共享", + "Free input images": "免费输入图片", + "0-9 images": "0-9 张图片", + "Extra input image price": "超额输入图片价格", + "Input audio price": "输入音频价格", + "Free in schema version 1": "schema 版本 1 免费", + "Server-side billing preview": "服务端计费预览", + "Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.": "预览使用本地草稿,不创建任务,也不产生钱包、Token、结算、日志或统计副作用。", + "Resolution": "分辨率", + "Requested output seconds": "请求输出秒数", + "Input video count": "输入视频数量", + "Input audio count": "输入音频数量", + "Input image count": "输入图片数量", + "Preview group ratio": "预览分组倍率", + "Include actual usage and refund preview": "包含实际用量和退款预览", + "Previewing...": "预览中...", + "Preview billing": "预览计费", + "Actual output milliseconds": "实际输出毫秒数", + "Actual input video milliseconds": "实际输入视频毫秒数", + "Actual input audio milliseconds": "实际输入音频毫秒数", + "Schema": "架构版本", + "Rule key": "规则键", + "Free": "免费", + "Save H3 billing profile": "保存 H3 计费配置" } } From 3c033bb7b4d0da7fb77eed47d99d71a3a7d3090c Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 6 Sep 2026 01:06:11 +0800 Subject: [PATCH 04/24] fix French H3 billing translation --- web/default/src/i18n/locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 6d5e4d26..6bf50fe9 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -5624,7 +5624,7 @@ "Input video count": "Nombre de vidéos d’entrée", "Input audio count": "Nombre d’audios d’entrée", "Input image count": "Nombre d’images d’entrée", - "Preview group ratio": "Coefficient de groupe pour l’aperçu", + "Preview group ratio": "Ratio de groupe pour l’aperçu", "Include actual usage and refund preview": "Inclure l’aperçu de l’usage réel et du remboursement", "Previewing...": "Aperçu en cours...", "Preview billing": "Prévisualiser la facturation", From 1c5c266b52f1a0cac17a780f6fb49857a86d561d Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 6 Sep 2026 03:23:21 +0800 Subject: [PATCH 05/24] unify minimax task billing configuration --- model/pricing.go | 2 +- model/pricing_test.go | 10 + relay/channel/task/hailuo/adaptor.go | 6 +- setting/task_billing_setting/defaults.go | 6 + setting/task_billing_setting/h3.go | 148 ++-- setting/task_billing_setting/h3_test.go | 29 + setting/task_billing_setting/rate_card.go | 101 +++ .../task_billing_setting/rate_card_test.go | 49 ++ .../drawers/model-mutate-drawer.tsx | 1 - .../src/features/system-settings/api.ts | 34 - .../billing/h3-billing-settings.tsx | 734 ------------------ .../billing/h3-billing-utils.test.ts | 93 --- .../billing/h3-billing-utils.ts | 251 ------ .../system-settings/billing/index.tsx | 1 - .../billing/section-registry.tsx | 11 - .../features/system-settings/models/index.tsx | 1 - .../models/task-rate-card-settings.tsx | 97 ++- .../src/features/system-settings/types.ts | 2 - web/default/src/i18n/locales/en.json | 2 + web/default/src/i18n/locales/fr.json | 2 + web/default/src/i18n/locales/ja.json | 2 + web/default/src/i18n/locales/ru.json | 2 + web/default/src/i18n/locales/vi.json | 2 + web/default/src/i18n/locales/zh.json | 2 + 24 files changed, 406 insertions(+), 1182 deletions(-) delete mode 100644 web/default/src/features/system-settings/billing/h3-billing-settings.tsx delete mode 100644 web/default/src/features/system-settings/billing/h3-billing-utils.test.ts delete mode 100644 web/default/src/features/system-settings/billing/h3-billing-utils.ts 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/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index c2b324fc..e04d1c67 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -221,7 +221,11 @@ func (a *TaskAdaptor) BuildTaskBillingPlan(c *gin.Context, info *relaycommon.Rel if info != nil { groupRatio = info.PriceData.GroupRatioInfo.GroupRatio } - return task_billing_setting.BuildH3BillingPlan(input, 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) { diff --git a/setting/task_billing_setting/defaults.go b/setting/task_billing_setting/defaults.go index c0e0f15d..9edf0dc3 100644 --- a/setting/task_billing_setting/defaults.go +++ b/setting/task_billing_setting/defaults.go @@ -25,12 +25,18 @@ func defaultRateCards() map[string]RateCard { row("pro_video", 1.2, "quality", "pro", "has_video_input", "true"), row("pro_no_video", 0.8, "quality", "pro", "has_video_input", "false"), } + minimaxConfig := defaultH3BillingConfig() return map[string]RateCard{ "kling/kling-v3-video-generation": perSecondCard("kling", v3Rows), "kling-v3": perSecondCard("kling", v3Rows), "kling/kling-v3-omni-video-generation": perSecondCard("kling", v3OmniRows), "kling-v3-omni": perSecondCard("kling", v3OmniRows), "kling-video-o1": perSecondCard("kling", o1Rows), + MinimaxBillingRuleKey: { + Vendor: "minimax", + BillingType: MinimaxBillingType, + BillingConfig: encodeBillingConfig(minimaxConfig), + }, } } diff --git a/setting/task_billing_setting/h3.go b/setting/task_billing_setting/h3.go index 016c9e60..94588871 100644 --- a/setting/task_billing_setting/h3.go +++ b/setting/task_billing_setting/h3.go @@ -12,9 +12,12 @@ import ( ) const ( - H3BillingSource = "minimax_hailuo" - H3BillingProfileKey = "minimax_h3_v2" - H3BillingMode = types.TaskBillingPlanModeBoundedActual + 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 @@ -95,6 +98,41 @@ func defaultH3BillingProfiles() map[string]H3BillingConfig { } } +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), "MiniMax-H3") +} + func newH3BillingProfileMap(profiles map[string]H3BillingConfig) *types.RWMap[string, H3BillingConfig] { m := types.NewRWMap[string, H3BillingConfig]() m.AddAll(profiles) @@ -138,42 +176,9 @@ func ValidateH3ProfilesJSON(raw string) error { func validateH3Profiles(profiles map[string]H3BillingConfig) error { for key, profile := range profiles { - 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 > 15 { - return fmt.Errorf("H3 billing profile %s input_video_max_seconds must be between 1 and 15", key) - } - if profile.InputImageFreeCount < 0 || profile.InputImageFreeCount > 9 { - return fmt.Errorf("H3 billing profile %s input_image_free_count must be between 0 and 9", key) - } - if err := validateResolutionPrices(key, "output_unit_price", profile.OutputUnitPrice); err != nil { + if err := validateH3BillingConfig(key, profile); 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) - } - } } if _, ok := profiles[H3BillingProfileKey]; !ok { return fmt.Errorf("H3 billing profile %s is required", H3BillingProfileKey) @@ -181,6 +186,46 @@ func validateH3Profiles(profiles map[string]H3BillingConfig) error { 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 > H3MaxOutputDurationSeconds { + return fmt.Errorf("H3 billing profile %s input_video_max_seconds must be between 1 and %d", key, H3MaxOutputDurationSeconds) + } + 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] @@ -214,11 +259,26 @@ func BuildH3BillingPlan(input H3BillingInput, groupRatio float64) (*types.TaskBi if !ok { return nil, fmt.Errorf("H3 billing profile %s is not configured", H3BillingProfileKey) } - return buildH3BillingPlan(*profile, input, groupRatio) + return buildH3BillingPlan(*profile, input, groupRatio, LegacyH3BillingSource, H3BillingProfileKey) +} + +// BuildH3BillingPlanForModels selects the structured MiniMax rule from the +// unified task rate-card option. The legacy profile remains a read-only +// fallback for installations that have not yet saved a unified rate-card JSON. +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) + plan, err := buildH3BillingPlan(config, input, groupRatio, LegacyH3BillingSource, H3BillingProfileKey) if err != nil { return nil, err } @@ -255,8 +315,8 @@ func PreviewH3Billing(config H3BillingConfig, input H3BillingInput, groupRatio f return preview, nil } -func buildH3BillingPlan(profile H3BillingConfig, input H3BillingInput, groupRatio float64) (*types.TaskBillingPlan, error) { - if err := validateH3Profiles(map[string]H3BillingConfig{H3BillingProfileKey: profile}); err != 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)) @@ -281,8 +341,8 @@ func buildH3BillingPlan(profile H3BillingConfig, input H3BillingInput, groupRati videoReserve = profile.InputVideoMaxSeconds } plan := &types.TaskBillingPlan{ - Source: H3BillingSource, - RuleKey: H3BillingProfileKey, + Source: source, + RuleKey: ruleKey, SchemaVersion: profile.SchemaVersion, Mode: profile.Mode, ConfigHash: hashH3BillingConfig(profile), @@ -473,7 +533,7 @@ func quoteH3PlanMilliseconds(plan *types.TaskBillingPlan, stage string, outputMs } func validateH3BillingPlan(plan *types.TaskBillingPlan) error { - if plan == nil || plan.Source != H3BillingSource || plan.RuleKey != H3BillingProfileKey || plan.SchemaVersion != 1 || plan.Mode != H3BillingMode || strings.TrimSpace(plan.ConfigHash) == "" { + if plan == nil || (plan.Source != H3BillingSource && plan.Source != LegacyH3BillingSource) || strings.TrimSpace(plan.RuleKey) == "" || plan.SchemaVersion != 1 || plan.Mode != H3BillingMode || strings.TrimSpace(plan.ConfigHash) == "" { return fmt.Errorf("H3 billing plan identity is invalid") } if plan.GroupRatio < 0 || math.IsNaN(plan.GroupRatio) || math.IsInf(plan.GroupRatio, 0) || plan.QuotaPerUnit <= 0 || math.IsNaN(plan.QuotaPerUnit) || math.IsInf(plan.QuotaPerUnit, 0) { diff --git a/setting/task_billing_setting/h3_test.go b/setting/task_billing_setting/h3_test.go index f864c2d8..f4451144 100644 --- a/setting/task_billing_setting/h3_test.go +++ b/setting/task_billing_setting/h3_test.go @@ -45,6 +45,35 @@ func TestBuildH3BillingPlanUsesOneAggregateInputVideoCap(t *testing.T) { 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 TestBuildH3BillingPlanDoesNotChargeFreeImagesOrAudio(t *testing.T) { withQuotaPerUnit(t, 1000) plan, err := BuildH3BillingPlan(H3BillingInput{ diff --git a/setting/task_billing_setting/rate_card.go b/setting/task_billing_setting/rate_card.go index adc6c155..19baa6a0 100644 --- a/setting/task_billing_setting/rate_card.go +++ b/setting/task_billing_setting/rate_card.go @@ -19,8 +19,12 @@ 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"` @@ -69,6 +73,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 { @@ -122,6 +132,28 @@ 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 len(card.BillingConfig) == 0 { + return fmt.Errorf("rate card %s billing_config is required", key) + } + if len(card.Rows) > 0 { + return fmt.Errorf("rate card %s structured billing cannot define rows", 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) } @@ -171,10 +203,63 @@ func findRateCard(models ...string) (*RateCard, string) { } if matchPattern(key, model) { card := rateCards[key] + if card.BillingType != "" && !isMinimaxH3Model(model) { + 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 { + return &card, model + } + } + + keys := make([]string, 0, len(rateCards)) + for key := range rateCards { + keys = append(keys, key) + } + sort.Strings(keys) + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" { + continue + } + for _, key := range keys { + card := rateCards[key] + if card.BillingType == MinimaxBillingType && strings.Contains(key, "*") && matchPattern(key, model) { + return &card, key + } + } + } + + if card, ok := rateCards[MinimaxBillingRuleKey]; ok && card.BillingType == MinimaxBillingType { + return &card, MinimaxBillingRuleKey + } + if card, ok := rateCards["minimax"]; ok && card.BillingType == MinimaxBillingType { + return &card, "minimax" + } return nil, "" } @@ -311,6 +396,7 @@ func cloneRateCards(src map[string]RateCard) map[string]RateCard { } func cloneRateCard(card RateCard) RateCard { + card.BillingConfig = cloneBillingConfig(card.BillingConfig) card.Defaults = cloneStringMap(card.Defaults) rows := make([]RateCardRow, len(card.Rows)) for i, row := range card.Rows { @@ -321,6 +407,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..5dfd2c82 100644 --- a/setting/task_billing_setting/rate_card_test.go +++ b/setting/task_billing_setting/rate_card_test.go @@ -79,6 +79,55 @@ 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 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) +} + +func TestHasRateCardRecognizesOnlyTheMiniMaxH3Alias(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)})) + }) + + require.True(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/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index 3d771423..fbd3aa0c 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -196,7 +196,6 @@ export function ModelMutateDrawer({ 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'task_billing_setting.rate_cards': '{}', - 'task_billing_setting.h3_profiles': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/api.ts b/web/default/src/features/system-settings/api.ts index e09653a2..2e078338 100644 --- a/web/default/src/features/system-settings/api.ts +++ b/web/default/src/features/system-settings/api.ts @@ -17,11 +17,6 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import { api } from '@/lib/api' -import type { - H3BillingPreview, - H3BillingPreviewScenario, - H3BillingProfile, -} from './billing/h3-billing-utils' import type { ConfirmPaymentComplianceResponse, FetchUpstreamRatiosRequest, @@ -45,35 +40,6 @@ export async function updateSystemOption(request: UpdateOptionRequest) { return res.data } -export async function previewH3Billing(request: { - profile: H3BillingProfile - scenario: H3BillingPreviewScenario - groupRatio: number -}) { - const res = await api.post<{ - success: boolean - message: string - data?: H3BillingPreview - }>('/api/option/h3_billing/preview', { - profile: request.profile, - resolution: request.scenario.resolution, - output_duration_seconds: request.scenario.outputDurationSeconds, - input_video_count: request.scenario.inputVideoCount, - input_audio_count: request.scenario.inputAudioCount, - input_image_count: request.scenario.inputImageCount, - group_ratio: request.groupRatio, - actual: request.scenario.actual - ? { - output_duration_ms: request.scenario.actual.outputDurationMs, - input_video_duration_ms: request.scenario.actual.inputVideoDurationMs, - input_audio_duration_ms: request.scenario.actual.inputAudioDurationMs, - input_image_count: request.scenario.actual.inputImageCount, - } - : undefined, - }) - return res.data -} - export async function updateTieredBillingConfig(request: { config: Record }) { diff --git a/web/default/src/features/system-settings/billing/h3-billing-settings.tsx b/web/default/src/features/system-settings/billing/h3-billing-settings.tsx deleted file mode 100644 index 8732c245..00000000 --- a/web/default/src/features/system-settings/billing/h3-billing-settings.tsx +++ /dev/null @@ -1,734 +0,0 @@ -/* -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 { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' -import * as z from 'zod' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useMutation } from '@tanstack/react-query' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { Separator } from '@/components/ui/separator' -import { previewH3Billing } from '../api' -import { - SettingsForm, - SettingsFormGrid, - SettingsFormGridItem, - SettingsControlGroup, -} from '../components/settings-form-layout' -import { SettingsPageFormActions } from '../components/settings-page-context' -import { SettingsSection } from '../components/settings-section' -import { useResetForm } from '../hooks/use-reset-form' -import { useUpdateOption } from '../hooks/use-update-option' -import { safeNumberFieldProps } from '../utils/numeric-field' -import { - buildH3BillingFormValues, - buildH3BillingProfile, - DEFAULT_H3_BILLING_PROFILE, - H3_BILLING_PROFILE_KEY, - fingerprintH3BillingProfile, - hasLegacyH3RateCard, - parseH3BillingProfiles, - serializeH3BillingProfiles, - type H3BillingPreview, - type H3BillingPreviewScenario, - type H3BillingProfile, - type H3BillingProfiles, - type H3Resolution, -} from './h3-billing-utils' - -const createH3BillingSchema = (t: (key: string) => string) => { - const decimalString = z - .string() - .trim() - .min(1, t('Enter a non-negative decimal price.')) - .refine( - (value) => /^\d+(?:\.\d+)?$/.test(value), - t('Enter a non-negative decimal price.') - ) - - return z.object({ - output768Price: decimalString, - output2KPrice: decimalString, - inputVideo768Price: decimalString, - inputVideo2KPrice: decimalString, - inputVideoMaxSeconds: z.number().int().min(1).max(15), - inputImageFreeCount: z.number().int().min(0).max(9), - inputImageExtraPrice: decimalString, - }) -} - -type H3BillingFormValues = z.infer> - -function getObservedTimestamp() { - return Date.now() -} - -type H3BillingSettingsProps = { - defaultValue: string - legacyRateCards: string -} - -type PreviewScenarioState = { - resolution: H3Resolution - outputDurationSeconds: number - inputVideoCount: number - inputAudioCount: number - inputImageCount: number - includeActual: boolean - actualOutputDurationMs: number - actualInputVideoDurationMs: number - actualInputAudioDurationMs: number -} - -const DEFAULT_SCENARIO: PreviewScenarioState = { - resolution: '768P', - outputDurationSeconds: 5, - inputVideoCount: 0, - inputAudioCount: 0, - inputImageCount: 0, - includeActual: false, - actualOutputDurationMs: 5000, - actualInputVideoDurationMs: 0, - actualInputAudioDurationMs: 0, -} - -function buildDefaultValues(profile: H3BillingProfile): H3BillingFormValues { - return buildH3BillingFormValues(profile) -} - -function buildPreviewScenario( - state: PreviewScenarioState -): H3BillingPreviewScenario { - const scenario: H3BillingPreviewScenario = { - resolution: state.resolution, - outputDurationSeconds: state.outputDurationSeconds, - inputVideoCount: state.inputVideoCount, - inputAudioCount: state.inputAudioCount, - inputImageCount: state.inputImageCount, - } - if (state.includeActual) { - scenario.actual = { - outputDurationMs: state.actualOutputDurationMs, - inputImageCount: state.inputImageCount, - ...(state.inputVideoCount > 0 - ? { inputVideoDurationMs: state.actualInputVideoDurationMs } - : {}), - ...(state.inputAudioCount > 0 - ? { inputAudioDurationMs: state.actualInputAudioDurationMs } - : {}), - } - } - return scenario -} - -function PreviewSummary({ preview }: { preview: H3BillingPreview }) { - const { t } = useTranslation() - const adjustment = preview.adjustment_quota - return ( -
-
- {[ - { label: t('Estimate'), quote: preview.estimate }, - { label: t('Reserve'), quote: preview.reserve }, - { label: t('Actual'), quote: preview.final }, - ].map((item) => ( -
-
{item.label}
- {item.quote ? ( -
- - {item.quote.price} - - - {item.quote.quota.toLocaleString()} {t('quota')} - -
- ) : ( -
- {t('Not included')} -
- )} -
- ))} -
- {adjustment !== undefined && ( -

- {adjustment < 0 - ? t('Estimated refund: {{count}} quota', { - count: preview.refund_quota ?? Math.abs(adjustment), - }) - : t('Additional quota required: {{count}}', { count: adjustment })} -

- )} -

- {t('Configuration hash')}: {preview.config_hash} -

-
- ) -} - -export function H3BillingSettings(props: H3BillingSettingsProps) { - const { t, i18n } = useTranslation() - const updateOption = useUpdateOption() - const previewMutation = useMutation({ mutationFn: previewH3Billing }) - const h3BillingSchema = useMemo(() => createH3BillingSchema(t), [t]) - const parsed = useMemo( - () => parseH3BillingProfiles(props.defaultValue || '{}'), - [props.defaultValue] - ) - const profile = - parsed.profiles[H3_BILLING_PROFILE_KEY] ?? DEFAULT_H3_BILLING_PROFILE - const formDefaults = useMemo(() => buildDefaultValues(profile), [profile]) - const form = useForm({ - resolver: zodResolver(h3BillingSchema), - mode: 'onChange', - defaultValues: formDefaults, - }) - useResetForm(form, formDefaults) - - const [scenario, setScenario] = useState(DEFAULT_SCENARIO) - const [groupRatio, setGroupRatio] = useState(1) - const [preview, setPreview] = useState<{ - data: H3BillingPreview - fingerprint: string - } | null>(null) - const [previewError, setPreviewError] = useState('') - const [lastSaved, setLastSaved] = useState<{ - configHash: string - observedAt: number - persistedValue: string - } | null>(null) - const previewRequestId = useRef(0) - const sourceValueRef = useRef(props.defaultValue) - - const invalidatePreview = useCallback(() => { - previewRequestId.current += 1 - setPreview(null) - setPreviewError('') - }, []) - const updateScenario = useCallback( - ( - key: K, - value: PreviewScenarioState[K] - ) => { - setScenario((current) => ({ ...current, [key]: value })) - invalidatePreview() - }, - [invalidatePreview] - ) - - useEffect(() => { - if (sourceValueRef.current === props.defaultValue) return - sourceValueRef.current = props.defaultValue - invalidatePreview() - setLastSaved((current) => - current?.persistedValue === props.defaultValue ? current : null - ) - }, [props.defaultValue, invalidatePreview]) - - const profileError = Boolean(parsed.error) - const legacyRateCard = useMemo( - () => hasLegacyH3RateCard(props.legacyRateCards), - [props.legacyRateCards] - ) - - const getDraftProfile = useCallback( - (values: H3BillingFormValues) => buildH3BillingProfile(profile, values), - [profile] - ) - - const handlePreview = async () => { - const valid = await form.trigger() - if (!valid || profileError) return - setPreviewError('') - const values = form.getValues() - const draftProfile = getDraftProfile(values) - const requestId = ++previewRequestId.current - try { - const response = await previewMutation.mutateAsync({ - profile: draftProfile, - scenario: buildPreviewScenario(scenario), - groupRatio: - Number.isFinite(groupRatio) && groupRatio >= 0 ? groupRatio : 0, - }) - if (requestId !== previewRequestId.current) return - if (!response.success || !response.data) { - const message = response.message || t('H3 billing preview failed') - setPreviewError(message) - toast.error(message) - return - } - setPreview({ - data: response.data, - fingerprint: fingerprintH3BillingProfile(draftProfile), - }) - } catch (error) { - if (requestId !== previewRequestId.current) return - const message = - error instanceof Error ? error.message : t('H3 billing preview failed') - setPreviewError(message) - toast.error(message) - } - } - - const handleSave = async (values: H3BillingFormValues) => { - if (profileError) return - const draftProfile = getDraftProfile(values) - const fingerprint = fingerprintH3BillingProfile(draftProfile) - if (!preview || preview.fingerprint !== fingerprint) { - return - } - const profiles: H3BillingProfiles = Object.fromEntries( - Object.entries(parsed.profiles).map(([key, value]) => [key, { ...value }]) - ) - profiles[H3_BILLING_PROFILE_KEY] = draftProfile - const persistedValue = serializeH3BillingProfiles(profiles) - const response = await updateOption.mutateAsync({ - key: 'task_billing_setting.h3_profiles', - value: persistedValue, - }) - if (response.success) { - setLastSaved({ - configHash: preview.data.config_hash, - observedAt: getObservedTimestamp(), - persistedValue, - }) - setPreview(null) - } - } - - return ( - -
- - {t('Structured H3 billing profile')} - - {t( - 'Configure the bounded-actual prices used by new MiniMax-H3 tasks. Existing tasks keep their submitted billing snapshot.' - )} - - - - {legacyRateCard && ( - - {t('Legacy rate card detected')} - - {t( - 'The legacy MiniMax-H3 rate card is kept for compatibility and is not this structured H3 profile.' - )} - - - )} - - {profileError && ( - - {t('H3 profile is unavailable')} - - {t( - 'The saved H3 profile is incomplete or invalid. Saving is disabled until the server returns a valid profile.' - )} - - - )} - -
- - -
-
-

- {t('Price per second')} -

-

- {t( - 'Prices are entered in the configured currency and validated again by the server.' - )} -

-
-
-
{t('Model')}: MiniMax-H3
-
{t('Channel')}: 35
-
-
- - {[ - ['output768Price', t('Output video / 768P')], - ['output2KPrice', t('Output video / 2K')], - ['inputVideo768Price', t('Input video / 768P')], - ['inputVideo2KPrice', t('Input video / 2K')], - ].map(([name, label]) => ( - ( - - {label} - - { - field.onChange(event) - invalidatePreview() - }} - /> - - {profile.currency} - - - )} - /> - ))} - -
- - -

- {t('Input limits and free allowance')} -

- - { - const numeric = safeNumberFieldProps(field) - return ( - - - {t('Input video reservation cap')} - - - { - numeric.onChange(event) - invalidatePreview() - }} - /> - - - {t('1-15 seconds, shared across input videos')} - - - - ) - }} - /> - { - const numeric = safeNumberFieldProps(field) - return ( - - {t('Free input images')} - - { - numeric.onChange(event) - invalidatePreview() - }} - /> - - {t('0-9 images')} - - - ) - }} - /> - ( - - {t('Extra input image price')} - - { - field.onChange(event) - invalidatePreview() - }} - /> - - - {profile.currency} / image - - - - )} - /> - - {t('Input audio price')} - - - {t('Free in schema version 1')} - - - -
- - -
-

- {t('Server-side billing preview')} -

-

- {t( - 'Preview uses the draft profile and has no task, wallet, token, settlement, log, or statistics side effects.' - )} -

-
- - - - - - updateScenario('outputDurationSeconds', value) - } - /> - updateScenario('inputVideoCount', value)} - /> - updateScenario('inputAudioCount', value)} - /> - updateScenario('inputImageCount', value)} - /> -
- - { - setGroupRatio(Number(event.target.value) || 0) - invalidatePreview() - }} - /> -
-
- -
- - -
- - {scenario.includeActual && ( -
- - updateScenario('actualOutputDurationMs', value) - } - /> - - updateScenario('actualInputVideoDurationMs', value) - } - /> - - updateScenario('actualInputAudioDurationMs', value) - } - /> -
- )} - - {preview && ( - <> - - - - )} - {previewError && ( -

{previewError}

- )} -
- - {lastSaved && ( - - {t('Saved successfully')} - -
- {t('Configuration hash')}: {lastSaved.configHash} -
-
- {t('Last updated:')}{' '} - {new Date(lastSaved.observedAt).toLocaleString( - i18n.resolvedLanguage ?? i18n.language - )} -
-
-
- )} - -
- {t('Schema')}: 1 - {t('Mode')}: bounded_actual - - {t('Rule key')}: {H3_BILLING_PROFILE_KEY} - - - {t('Audio')}: {t('Free')} - -
- - -
-
-
-
- ) -} - -type PreviewNumberFieldProps = { - label: string - value: number - min: number - max: number - onChange: (value: number) => void -} - -function PreviewNumberField(props: PreviewNumberFieldProps) { - const id = useId() - return ( -
- - props.onChange(Number(event.target.value) || 0)} - /> -
- ) -} diff --git a/web/default/src/features/system-settings/billing/h3-billing-utils.test.ts b/web/default/src/features/system-settings/billing/h3-billing-utils.test.ts deleted file mode 100644 index fa306517..00000000 --- a/web/default/src/features/system-settings/billing/h3-billing-utils.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* -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 assert from 'node:assert/strict' -import { describe, test } from 'node:test' -import { - buildH3BillingFormValues, - buildH3BillingProfile, - DEFAULT_H3_BILLING_PROFILE, - H3_BILLING_PROFILE_KEY, - hasLegacyH3RateCard, - parseH3BillingProfiles, - serializeH3BillingProfiles, -} from './h3-billing-utils' - -describe('H3 billing configuration utilities', () => { - test('parses the profile and preserves other structured profiles', () => { - const otherProfile = { - ...DEFAULT_H3_BILLING_PROFILE, - currency: 'CNY', - } - const raw = JSON.stringify({ - [H3_BILLING_PROFILE_KEY]: DEFAULT_H3_BILLING_PROFILE, - other_profile: otherProfile, - }) - - const result = parseH3BillingProfiles(raw) - - assert.equal(result.error, undefined) - assert.equal(result.profiles.other_profile.currency, 'CNY') - assert.deepEqual( - buildH3BillingFormValues(result.profiles[H3_BILLING_PROFILE_KEY]), - { - output768Price: '0.08', - output2KPrice: '0.13', - inputVideo768Price: '0.08', - inputVideo2KPrice: '0.13', - inputVideoMaxSeconds: 15, - inputImageFreeCount: 5, - inputImageExtraPrice: '0.04', - } - ) - }) - - test('rejects missing profile instead of allowing an empty save', () => { - const result = parseH3BillingProfiles('{}') - - assert.match(result.error || '', /minimax_h3_v2 is required/) - }) - - test('builds an editable profile without changing immutable audio pricing', () => { - const profile = buildH3BillingProfile(DEFAULT_H3_BILLING_PROFILE, { - output768Price: '0.10', - output2KPrice: '0.20', - inputVideo768Price: '0.11', - inputVideo2KPrice: '0.21', - inputVideoMaxSeconds: 10, - inputImageFreeCount: 2, - inputImageExtraPrice: '0.05', - }) - - assert.equal(profile.output_unit_price['768P'], '0.10') - assert.equal(profile.input_video_max_seconds, 10) - assert.equal(profile.input_audio_unit_price, '0') - assert.equal( - serializeH3BillingProfiles({ - [H3_BILLING_PROFILE_KEY]: profile, - }).includes('0.10'), - true - ) - }) - - test('detects only the legacy MiniMax-H3 rate-card key', () => { - assert.equal(hasLegacyH3RateCard('{"MiniMax-H3":{"unit":"second"}}'), true) - assert.equal(hasLegacyH3RateCard('{"minimax_h3_v2":{}}'), false) - assert.equal(hasLegacyH3RateCard('{invalid'), false) - }) -}) diff --git a/web/default/src/features/system-settings/billing/h3-billing-utils.ts b/web/default/src/features/system-settings/billing/h3-billing-utils.ts deleted file mode 100644 index a5ccac5d..00000000 --- a/web/default/src/features/system-settings/billing/h3-billing-utils.ts +++ /dev/null @@ -1,251 +0,0 @@ -/* -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 -*/ -export const H3_BILLING_PROFILE_KEY = 'minimax_h3_v2' - -export type H3Resolution = '768P' | '2K' - -export type H3BillingProfile = { - schema_version: number - mode: string - currency: string - output_unit_price: Record - input_video_unit_price: Record - input_video_max_seconds: number - input_image_free_count: number - input_image_extra_unit_price: string - input_audio_unit_price: string -} - -export type H3BillingProfiles = Record - -export type H3BillingFormValues = { - output768Price: string - output2KPrice: string - inputVideo768Price: string - inputVideo2KPrice: string - inputVideoMaxSeconds: number - inputImageFreeCount: number - inputImageExtraPrice: string -} - -export type H3BillingPreviewScenario = { - resolution: H3Resolution - outputDurationSeconds: number - inputVideoCount: number - inputAudioCount: number - inputImageCount: number - actual?: { - outputDurationMs: number - inputVideoDurationMs?: number - inputAudioDurationMs?: number - inputImageCount: number - } -} - -export type H3BillingQuoteComponent = { - key: string - unit: string - quantity: number - quantity_decimal?: string - unit_price: string - price: string -} - -export type H3BillingQuote = { - stage: string - price: string - quota: number - output_seconds: number - input_video_seconds: number - input_audio_seconds: number - output_duration_ms: number - input_video_duration_ms: number - input_audio_duration_ms: number - input_image_count: number - components: H3BillingQuoteComponent[] -} - -export type H3BillingPreview = { - config_hash: string - quota_per_unit: number - group_ratio: number - estimate: H3BillingQuote - reserve: H3BillingQuote - final?: H3BillingQuote - adjustment_quota?: number - refund_quota?: number -} - -export const DEFAULT_H3_BILLING_PROFILE: H3BillingProfile = { - 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', -} - -export const DEFAULT_H3_BILLING_PROFILES: H3BillingProfiles = { - [H3_BILLING_PROFILE_KEY]: DEFAULT_H3_BILLING_PROFILE, -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function isProfile(value: unknown): value is H3BillingProfile { - if (!isRecord(value)) return false - const output = value.output_unit_price - const inputVideo = value.input_video_unit_price - return ( - typeof value.schema_version === 'number' && - typeof value.mode === 'string' && - typeof value.currency === 'string' && - isRecord(output) && - typeof output['768P'] === 'string' && - typeof output['2K'] === 'string' && - isRecord(inputVideo) && - typeof inputVideo['768P'] === 'string' && - typeof inputVideo['2K'] === 'string' && - typeof value.input_video_max_seconds === 'number' && - typeof value.input_image_free_count === 'number' && - typeof value.input_image_extra_unit_price === 'string' && - typeof value.input_audio_unit_price === 'string' - ) -} - -export function parseH3BillingProfiles(value: string): { - profiles: H3BillingProfiles - error?: string -} { - if (!value.trim()) { - return { profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES) } - } - - try { - const parsed: unknown = JSON.parse(value) - if (!isRecord(parsed)) { - return { - profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), - error: 'H3 billing profiles must be a JSON object', - } - } - const profiles: H3BillingProfiles = {} - for (const [key, profile] of Object.entries(parsed)) { - if (!isProfile(profile)) { - return { - profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), - error: `H3 billing profile ${key} is incomplete`, - } - } - profiles[key] = cloneProfile(profile) - } - if (!profiles[H3_BILLING_PROFILE_KEY]) { - return { - profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), - error: `H3 billing profile ${H3_BILLING_PROFILE_KEY} is required`, - } - } - return { profiles } - } catch (error) { - return { - profiles: cloneProfiles(DEFAULT_H3_BILLING_PROFILES), - error: error instanceof Error ? error.message : 'Invalid H3 billing JSON', - } - } -} - -export function buildH3BillingFormValues( - profile: H3BillingProfile -): H3BillingFormValues { - return { - output768Price: profile.output_unit_price['768P'], - output2KPrice: profile.output_unit_price['2K'], - inputVideo768Price: profile.input_video_unit_price['768P'], - inputVideo2KPrice: profile.input_video_unit_price['2K'], - inputVideoMaxSeconds: profile.input_video_max_seconds, - inputImageFreeCount: profile.input_image_free_count, - inputImageExtraPrice: profile.input_image_extra_unit_price, - } -} - -export function buildH3BillingProfile( - current: H3BillingProfile, - values: H3BillingFormValues -): H3BillingProfile { - return { - ...cloneProfile(current), - output_unit_price: { - ...current.output_unit_price, - '768P': values.output768Price.trim(), - '2K': values.output2KPrice.trim(), - }, - input_video_unit_price: { - ...current.input_video_unit_price, - '768P': values.inputVideo768Price.trim(), - '2K': values.inputVideo2KPrice.trim(), - }, - input_video_max_seconds: values.inputVideoMaxSeconds, - input_image_free_count: values.inputImageFreeCount, - input_image_extra_unit_price: values.inputImageExtraPrice.trim(), - input_audio_unit_price: '0', - } -} - -export function serializeH3BillingProfiles(profiles: H3BillingProfiles) { - return JSON.stringify(profiles) -} - -export function fingerprintH3BillingProfile(profile: H3BillingProfile) { - return JSON.stringify(profile) -} - -export function hasLegacyH3RateCard(value: string) { - try { - const parsed: unknown = JSON.parse(value) - return ( - isRecord(parsed) && - Object.keys(parsed).some((key) => - key.toLowerCase().includes('minimax-h3') - ) - ) - } catch { - return false - } -} - -function cloneProfile(profile: H3BillingProfile): H3BillingProfile { - return { - ...profile, - output_unit_price: { ...profile.output_unit_price }, - input_video_unit_price: { ...profile.input_video_unit_price }, - } -} - -function cloneProfiles(profiles: H3BillingProfiles): H3BillingProfiles { - return Object.fromEntries( - Object.entries(profiles).map(([key, profile]) => [ - key, - cloneProfile(profile), - ]) - ) -} diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 1818c985..7db296c0 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -51,7 +51,6 @@ const defaultBillingSettings: BillingSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'task_billing_setting.rate_cards': '{}', - 'task_billing_setting.h3_profiles': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 8182e813..d97ff3e1 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -26,7 +26,6 @@ import { TaskRateCardSettings } from '../models/task-rate-card-settings' import { TieredBillingSettings } from '../models/tiered-billing-settings' import type { BillingSettings } from '../types' import { createSectionRegistry } from '../utils/section-registry' -import { H3BillingSettings } from './h3-billing-settings' const getModelDefaults = (settings: BillingSettings) => ({ ModelPrice: settings.ModelPrice, @@ -126,16 +125,6 @@ const BILLING_SECTIONS = [ /> ), }, - { - id: 'h3-billing', - titleKey: 'MiniMax-H3 Billing', - build: (settings: BillingSettings) => ( - - ), - }, { id: 'tiered-billing', titleKey: 'Tiered Billing', diff --git a/web/default/src/features/system-settings/models/index.tsx b/web/default/src/features/system-settings/models/index.tsx index 8bf91ffd..482877ad 100644 --- a/web/default/src/features/system-settings/models/index.tsx +++ b/web/default/src/features/system-settings/models/index.tsx @@ -55,7 +55,6 @@ const defaultModelSettings: ModelSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'task_billing_setting.rate_cards': '{}', - 'task_billing_setting.h3_profiles': '{}', 'tool_price_setting.prices': '{}', TopupGroupRatio: '', GroupRatio: '', 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..14a379d3 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 @@ -32,13 +32,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 +116,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 } @@ -137,6 +173,7 @@ function inferVendor(model: string, card: Record) { const normalizedModel = normalizeVendor(model) if (normalizedModel.includes('kling')) return 'kling' + if (normalizedModel.includes('minimax')) return 'minimax' if (normalizedModel.includes('sora') || normalizedModel.includes('openai')) { return 'openai' } @@ -166,7 +203,7 @@ function sortVendors(a: VendorSummary, b: VendorSummary) { function buildVendorSummary(value: string): VendorSummary[] { const trimmed = value.trim() - let parsed: unknown = {} + let parsed: unknown try { parsed = trimmed ? JSON.parse(trimmed) : {} @@ -232,11 +269,14 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ [t] ) - const handleUseExample = useCallback(() => { - setText(KLING_RATE_CARD_EXAMPLE) - setError('') - toast.success(t('Example loaded. Review prices before saving.')) - }, [t]) + const handleUseExample = useCallback( + (example: string) => { + setText(example) + setError('') + toast.success(t('Example loaded. Review prices before saving.')) + }, + [t] + ) const handleSave = useCallback(async () => { if (error) { @@ -367,7 +407,7 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ type='button' variant='outline' size='sm' - onClick={handleUseExample} + onClick={() => handleUseExample(KLING_RATE_CARD_EXAMPLE)} > {t('Use example')} @@ -383,6 +423,47 @@ export const TaskRateCardSettings = memo(function TaskRateCardSettings({ /> +
+
+
+

+ {t('MiniMax billing example')} +

+

+ {t( + 'Uses the unified billing_type and billing_config shape for output video, input video, images, and audio.' + )} +

+
+
+ + {t('Copy example')} + + +
+
+