Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
254d315
update
CSCITech Sep 5, 2026
5b5f1a1
fix H3 task validation and usage regressions
CSCITech Sep 5, 2026
f81ca8a
update
CSCITech Sep 5, 2026
3c033bb
fix French H3 billing translation
CSCITech Sep 5, 2026
1c5c266
unify minimax task billing configuration
CSCITech Sep 5, 2026
065641e
Harden MiniMax task billing validation
CSCITech Sep 5, 2026
6ec9d42
fix: complete MiniMax H3 task submission safeguards
CSCITech Sep 5, 2026
0f4d181
fix: improve task rate card example accessibility
CSCITech Sep 5, 2026
bcb9f10
test: strengthen async task billing snapshots
CSCITech Sep 5, 2026
f8b3f98
fix: keep H3 tasks pending without result URL
CSCITech Sep 5, 2026
46f584f
fix: validate H3 callback and billing keys
CSCITech Sep 6, 2026
d42c5dc
fix: ignore structured wildcard task billing cards
CSCITech Sep 6, 2026
ecf3d19
fix: align Japanese settlement terminology
CSCITech Sep 6, 2026
bf3a39e
fix: exclude structured cards from legacy pricing signal
CSCITech Sep 6, 2026
73330f4
fix: preserve legacy H3 billing profile fallback
CSCITech Sep 6, 2026
9779721
refactor: reuse shared task status constants
CSCITech Sep 6, 2026
12df376
style: add explicit task billing return types
CSCITech Sep 6, 2026
6a33bc0
fix: harden H3 task billing validation
CSCITech Sep 6, 2026
fe8c61d
fix: align French billing quota terminology
CSCITech Sep 6, 2026
ba9f3a4
fix: reject mixed H3 reference media
CSCITech Sep 6, 2026
3b3108c
refactor: remove redundant task billing validation
CSCITech Sep 6, 2026
2afe85c
fix: mark MiniMax billing template preview-only
CSCITech Sep 6, 2026
4b8f847
perf: stabilize task billing example callbacks
CSCITech Sep 6, 2026
6720b56
fix: preserve H3 preview usage semantics
CSCITech Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions constant/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const (
TaskActionKlingOmniVideo = "klingOmniVideo"
)

const TaskModelMiniMaxH3 = "MiniMax-H3"

var SunoModel2Action = map[string]string{
"suno_music": SunoActionMusic,
"suno_lyrics": SunoActionLyrics,
Expand Down
80 changes: 80 additions & 0 deletions controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -118,6 +119,76 @@ type OptionUpdateRequest struct {
Value any `json:"value"`
}

type H3BillingPreviewActual struct {
OutputDurationMs *int64 `json:"output_duration_ms,omitempty"`
InputVideoDurationMs *int64 `json:"input_video_duration_ms,omitempty"`
InputAudioDurationMs *int64 `json:"input_audio_duration_ms,omitempty"`
InputImageCount *int64 `json:"input_image_count,omitempty"`
}

type H3BillingPreviewRequest struct {
Profile task_billing_setting.H3BillingConfig `json:"profile"`
Resolution string `json:"resolution"`
OutputDurationSeconds int64 `json:"output_duration_seconds"`
InputVideoCount int64 `json:"input_video_count"`
InputAudioCount int64 `json:"input_audio_count"`
InputImageCount int64 `json:"input_image_count"`
GroupRatio float64 `json:"group_ratio"`
Actual *H3BillingPreviewActual `json:"actual,omitempty"`
}

func buildH3BillingPreviewUsage(actual *H3BillingPreviewActual) *types.TaskUsage {
if actual == nil {
return nil
}
if actual.OutputDurationMs == nil && actual.InputVideoDurationMs == nil && actual.InputAudioDurationMs == nil && actual.InputImageCount == nil {
return nil
}
completeness := types.TaskUsageCompletenessPartial
if actual.OutputDurationMs != nil && actual.InputImageCount != nil {
completeness = types.TaskUsageCompletenessComplete
}
return &types.TaskUsage{
OutputDurationMs: actual.OutputDurationMs,
InputVideoDurationMs: actual.InputVideoDurationMs,
InputAudioDurationMs: actual.InputAudioDurationMs,
InputImageCount: actual.InputImageCount,
Completeness: completeness,
}
}

func PreviewH3Billing(c *gin.Context) {
var request H3BillingPreviewRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}

usage := buildH3BillingPreviewUsage(request.Actual)
preview, err := task_billing_setting.PreviewH3Billing(request.Profile, task_billing_setting.H3BillingInput{
Resolution: request.Resolution,
OutputDurationSeconds: request.OutputDurationSeconds,
InputVideoCount: request.InputVideoCount,
InputAudioCount: request.InputAudioCount,
InputImageCount: request.InputImageCount,
}, request.GroupRatio, usage)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "H3 计费预览失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": preview,
})
}

func UpdateOption(c *gin.Context) {
var option OptionUpdateRequest
err := common.DecodeJson(c.Request.Body, &option)
Expand Down Expand Up @@ -294,6 +365,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "task_billing_setting.h3_profiles":
err = task_billing_setting.ValidateH3ProfilesJSON(option.Value.(string))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "H3 计费配置失败: " + err.Error(),
})
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
Expand Down
142 changes: 142 additions & 0 deletions controller/option_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -32,3 +34,143 @@ func TestUpdateOptionRejectsUnregisteredKey(t *testing.T) {

require.Equal(t, http.StatusBadRequest, recorder.Code)
}

func TestPreviewH3BillingReturnsDraftQuotesWithoutMutatingConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
originalQuotaPerUnit := common.QuotaPerUnit
common.QuotaPerUnit = 1000
t.Cleanup(func() { common.QuotaPerUnit = originalQuotaPerUnit })

originalProfiles := task_billing_setting.GetH3BillingProfilesCopy()
profile, ok := task_billing_setting.GetH3BillingProfileCopy(task_billing_setting.H3BillingProfileKey)
require.True(t, ok)
profile.OutputUnitPrice["768P"] = "0.10"
profile.InputVideoUnitPrice["768P"] = "0.10"
request := H3BillingPreviewRequest{
Profile: *profile,
Resolution: "768P",
OutputDurationSeconds: 5,
InputVideoCount: 1,
GroupRatio: 1,
Actual: &H3BillingPreviewActual{
OutputDurationMs: testInt64Pointer(5_000),
InputVideoDurationMs: testInt64Pointer(7_500),
InputImageCount: testInt64Pointer(0),
},
}
body, err := common.Marshal(request)
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/option/h3_billing/preview", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")

PreviewH3Billing(ctx)

require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data task_billing_setting.H3BillingPreview `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
require.Equal(t, 2000, response.Data.Reserve.Quota)
require.Equal(t, 1250, response.Data.Final.Quota)
require.Equal(t, 750, *response.Data.RefundQuota)
require.Equal(t, originalProfiles, task_billing_setting.GetH3BillingProfilesCopy())
}

func TestPreviewH3BillingRejectsInvalidDraft(t *testing.T) {
gin.SetMode(gin.TestMode)
profile, ok := task_billing_setting.GetH3BillingProfileCopy(task_billing_setting.H3BillingProfileKey)
require.True(t, ok)
profile.InputAudioUnitPrice = "0.01"
body, err := common.Marshal(H3BillingPreviewRequest{
Profile: *profile,
Resolution: "768P",
OutputDurationSeconds: 5,
GroupRatio: 1,
})
require.NoError(t, err)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/option/h3_billing/preview", bytes.NewReader(body))

PreviewH3Billing(ctx)

require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"success":false`)
require.Contains(t, recorder.Body.String(), "input_audio_unit_price must be zero")
}

func TestPreviewH3BillingDerivesActualUsageCompleteness(t *testing.T) {
gin.SetMode(gin.TestMode)
profile, ok := task_billing_setting.GetH3BillingProfileCopy(task_billing_setting.H3BillingProfileKey)
require.True(t, ok)

tests := []struct {
name string
actual *H3BillingPreviewActual
wantSuccess bool
wantFinal bool
wantMessagePart string
}{
{
name: "empty actual is treated as absent",
actual: &H3BillingPreviewActual{},
wantSuccess: true,
},
{
name: "missing output duration is partial",
actual: &H3BillingPreviewActual{
InputImageCount: testInt64Pointer(0),
},
wantMessagePart: "not automatically billable: partial",
},
{
name: "missing image count is partial",
actual: &H3BillingPreviewActual{
OutputDurationMs: testInt64Pointer(5_000),
},
wantMessagePart: "not automatically billable: partial",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
body, err := common.Marshal(H3BillingPreviewRequest{
Profile: *profile,
Resolution: "768P",
OutputDurationSeconds: 5,
GroupRatio: 1,
Actual: test.actual,
})
require.NoError(t, err)

recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/option/h3_billing/preview", bytes.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
PreviewH3Billing(ctx)

require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Message string `json:"message"`
Data *task_billing_setting.H3BillingPreview `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, test.wantSuccess, response.Success)
if test.wantSuccess {
require.NotNil(t, response.Data)
require.Equal(t, test.wantFinal, response.Data.Final != nil)
return
}
require.Contains(t, response.Message, test.wantMessagePart)
})
}
}

func testInt64Pointer(value int64) *int64 {
return &value
}
5 changes: 2 additions & 3 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -817,9 +819,6 @@ func handleConfigUpdate(key, value string) (bool, error) {
InvalidatePricingCache()
ratio_setting.InvalidateExposedDataCache()
} else if configName == "task_billing_setting" {
if configKey == "rate_cards" {
_ = task_billing_setting.ValidateRateCardsJSON(value)
}
InvalidatePricingCache()
} else if configName == "theme" {
system_setting.UpdateAndSyncTheme()
Expand Down
12 changes: 12 additions & 0 deletions model/option_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,25 @@ 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"))
})
}
}

func TestValidateOptionUpdateRejectsSemanticallyInvalidTaskBillingConfigs(t *testing.T) {
for key, value := range map[string]string{
"task_billing_setting.rate_cards": `{ "invalid": { "billing_type": "unsupported" } }`,
"task_billing_setting.h3_profiles": `{}`,
} {
t.Run(key, func(t *testing.T) {
require.Error(t, validateOptionUpdate(key, value))
})
}
}

func TestNormalizeDataExportIntervalRejectsUnsafeValues(t *testing.T) {
for _, value := range []string{"0", "-1", "1441", "not-a-number"} {
t.Run(value, func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion model/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
10 changes: 10 additions & 0 deletions model/pricing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions model/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"` // 渠道关闭完成态差额结算时按提交快照跳过
Expand Down
13 changes: 13 additions & 0 deletions relay/channel/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading
Loading