diff --git a/controller/audit.go b/controller/audit.go index 176a6699..1adeed7f 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -35,6 +35,8 @@ var auditContentTemplates = map[string]string{ "billing.reconciliation_review": "Reviewed billing settlement ${settlement_id} (block user: ${block_user})", "billing.reconciliation_review_batch": "Reviewed and closed ${count} billing reconciliation alerts", "billing.manual_task_settlement_complete": "Completed manual task billing settlement ${settlement_id} with exact quota ${actual_quota}", + "billing.manual_task_settlement_batch_complete": "Completed ${completed_count} manual task billing settlements (${failed_count} failed)", + "billing.manual_task_settlement_batch_zero": "Completed ${completed_count} manual task settlements with zero quota (${failed_count} failed)", "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", diff --git a/controller/smart_ops_alert.go b/controller/smart_ops_alert.go index e82338f8..641c964a 100644 --- a/controller/smart_ops_alert.go +++ b/controller/smart_ops_alert.go @@ -68,6 +68,16 @@ type manualTaskBillingCompletionRequest struct { Note string `json:"note"` } +type manualTaskBillingBatchCompletionRequest struct { + Items []manualTaskBillingBatchCompletionItem `json:"items"` +} + +type manualTaskBillingBatchCompletionItem struct { + ID int64 `json:"id"` + Revision int64 `json:"revision"` + ActualQuota *int64 `json:"actual_quota"` +} + // ReviewBillingSettlements closes a bounded set of current reconciliation // alerts after the administrator acknowledges them. func ReviewBillingSettlements(c *gin.Context) { @@ -236,6 +246,77 @@ func CompleteManualTaskBillingSettlement(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "data": result}) } +// CompleteManualTaskBillingSettlementsZero performs an explicit root-admin +// zero-quota settlement for selected MiniMax-H3 task alerts. Ordinary +// reconciliation alerts are never accepted by this endpoint. +func CompleteManualTaskBillingSettlementsZero(c *gin.Context) { + var request billingSettlementBatchReviewRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid manual task billing batch completion request"}) + return + } + result, err := service.CompleteManualTaskBillingSettlementsZero(request.Items, c.GetInt("id")) + if err != nil { + status := http.StatusInternalServerError + message := "failed to complete manual task billing settlements" + if errors.Is(err, service.ErrInvalidBillingSettlementReconciliationReview) { + status = http.StatusBadRequest + message = err.Error() + } else { + common.SysError(fmt.Sprintf("failed to complete manual task billing settlements: %v", err)) + } + c.JSON(status, gin.H{"success": false, "message": message}) + return + } + recordManageAudit(c, "billing.manual_task_settlement_batch_zero", map[string]interface{}{ + "completed_count": result.CompletedCount, + "failed_count": result.FailedCount, + "settlement_ids": result.SettlementIDs, + "failed": result.Failed, + }) + c.JSON(http.StatusOK, gin.H{"success": true, "data": result}) +} + +// CompleteManualTaskBillingSettlements applies administrator-supplied exact +// final quotas to a bounded set of task-finalization alerts. Each item carries +// its own amount so reservations from different tasks cannot be conflated. +func CompleteManualTaskBillingSettlements(c *gin.Context) { + var request manualTaskBillingBatchCompletionRequest + if err := common.DecodeJson(c.Request.Body, &request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid manual task billing batch completion request"}) + return + } + targets := make([]service.ManualTaskBillingCompletionTarget, len(request.Items)) + for index, item := range request.Items { + targets[index] = service.ManualTaskBillingCompletionTarget{ + ID: item.ID, + Revision: item.Revision, + ActualQuota: item.ActualQuota, + } + } + result, err := service.CompleteManualTaskBillingSettlements(targets, c.GetInt("id")) + if err != nil { + status := http.StatusInternalServerError + message := "failed to complete manual task billing settlements" + if errors.Is(err, service.ErrInvalidBillingSettlementReconciliationReview) { + status = http.StatusBadRequest + message = err.Error() + } + if status == http.StatusInternalServerError { + common.SysError(fmt.Sprintf("failed to complete manual task billing settlements: %v", err)) + } + c.JSON(status, gin.H{"success": false, "message": message}) + return + } + recordManageAudit(c, "billing.manual_task_settlement_batch_complete", map[string]interface{}{ + "completed_count": result.CompletedCount, + "failed_count": result.FailedCount, + "settlement_ids": result.SettlementIDs, + "failed": result.Failed, + }) + c.JSON(http.StatusOK, gin.H{"success": true, "data": result}) +} + // GetBillingSettlementReconciliation exposes bounded, read-only evidence for // open billing reconciliation records. It does not retry or mutate records. func GetBillingSettlementReconciliation(c *gin.Context) { diff --git a/controller/smart_ops_alert_test.go b/controller/smart_ops_alert_test.go index efc623cd..fb8e1eea 100644 --- a/controller/smart_ops_alert_test.go +++ b/controller/smart_ops_alert_test.go @@ -10,6 +10,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/model" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" @@ -39,6 +40,124 @@ func TestBillingSettlementMutationRequestsPreserveExplicitFalse(t *testing.T) { assert.Nil(t, omittedCompletion.ActualQuota) } +func TestCompleteManualTaskBillingSettlementsZeroDecodesEmptyBatch(t *testing.T) { + var request billingSettlementBatchReviewRequest + require.NoError(t, common.Unmarshal([]byte(`{"items":[]}`), &request)) + assert.Empty(t, request.Items) +} + +func TestCompleteManualTaskBillingSettlementsDecodesPerItemExplicitZero(t *testing.T) { + var request manualTaskBillingBatchCompletionRequest + require.NoError(t, common.Unmarshal([]byte(`{"items":[{"id":9,"revision":2,"actual_quota":0},{"id":10,"revision":3,"actual_quota":40}]}`), &request)) + require.Len(t, request.Items, 2) + require.NotNil(t, request.Items[0].ActualQuota) + assert.Zero(t, *request.Items[0].ActualQuota) + require.NotNil(t, request.Items[1].ActualQuota) + assert.EqualValues(t, 40, *request.Items[1].ActualQuota) + + var omitted manualTaskBillingBatchCompletionRequest + require.NoError(t, common.Unmarshal([]byte(`{"items":[{"id":9,"revision":2}]}`), &omitted)) + require.Len(t, omitted.Items, 1) + assert.Nil(t, omitted.Items[0].ActualQuota) +} + +func TestCompleteManualTaskBillingSettlementsZeroBlocksBatchWithIneligibleTarget(t *testing.T) { + gin.SetMode(gin.TestMode) + oldDB := model.DB + oldLogDB := model.LOG_DB + oldRedisEnabled := common.RedisEnabled + oldMemoryCacheEnabled := common.MemoryCacheEnabled + oldLogConsumeEnabled := common.LogConsumeEnabled + common.RedisEnabled = false + common.MemoryCacheEnabled = false + common.LogConsumeEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.Token{}, + &model.Task{}, + &model.BillingSettlement{}, + &model.CacheInvalidationTask{}, + &model.Log{}, + )) + model.DB = db + model.LOG_DB = db + t.Cleanup(func() { + if sqlDB, dbErr := db.DB(); dbErr == nil { + _ = sqlDB.Close() + } + model.DB = oldDB + model.LOG_DB = oldLogDB + common.RedisEnabled = oldRedisEnabled + common.MemoryCacheEnabled = oldMemoryCacheEnabled + common.LogConsumeEnabled = oldLogConsumeEnabled + }) + + admin := model.User{Id: 7041, Username: "smart-ops-zero-root", AffCode: "smart-ops-zero-root-aff", Role: common.RoleRootUser, Status: common.UserStatusEnabled, Group: "default"} + owner := model.User{Id: 7042, Username: "smart-ops-zero-owner", AffCode: "smart-ops-zero-owner-aff", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", Quota: 900} + token := model.Token{Id: 7043, UserId: owner.Id, Key: "smart-ops-zero-token", Name: "smart-ops-zero-token", Status: common.TokenStatusEnabled, RemainQuota: 100, UsedQuota: 100, Group: "default"} + require.NoError(t, db.Create(&admin).Error) + require.NoError(t, db.Create(&owner).Error) + require.NoError(t, db.Create(&token).Error) + + makeManualTask := func(taskID string, modelName string) (model.Task, model.BillingSettlement) { + task := model.Task{ + TaskID: taskID, UserId: owner.Id, Group: "default", ChannelId: 35, Quota: 100, + Status: model.TaskStatusInProgress, CreatedAt: 1, UpdatedAt: 1, + Properties: model.Properties{OriginModelName: modelName, UpstreamModelName: modelName}, + PrivateData: model.TaskPrivateData{ + TokenId: token.Id, BillingSource: "wallet", + BillingContext: &model.TaskBillingContext{OriginModelName: modelName}, + }, + } + task.SetData(map[string]any{"provider_status": "complete"}) + require.NoError(t, db.Create(&task).Error) + settlement := model.BillingSettlement{ + OperationKey: model.BillingTaskFinalizeOperationKey(task.ID), Source: model.BillingSettlementSourceWallet, + UserID: owner.Id, TokenID: token.Id, TaskID: task.ID, TaskQuota: 100, TaskQuotaTarget: 100, + Status: model.BillingSettlementStatusManual, LastError: "provider usage requires an exact quota", + CreatedAt: 1, UpdatedAt: 1, Revision: 1, + } + require.NoError(t, db.Create(&settlement).Error) + return task, settlement + } + + _, h3Settlement := makeManualTask("smart-ops-zero-h3", constant.TaskModelMiniMaxH3) + _, ordinarySettlement := makeManualTask("smart-ops-zero-ordinary", "ordinary-task-model") + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodPost, + "/api/smart-ops/billing-settlements/complete-tasks-zero", + bytes.NewBufferString(fmt.Sprintf(`{"items":[{"id":%d,"revision":1},{"id":%d,"revision":1}]}`, h3Settlement.ID, ordinarySettlement.ID)), + ) + ctx.Set("id", admin.Id) + ctx.Set("username", admin.Username) + ctx.Set("role", admin.Role) + + CompleteManualTaskBillingSettlementsZero(ctx) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), `"completed_count":0`) + assert.Contains(t, recorder.Body.String(), `"failed_count":2`) + assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"settlement_id":%d,"code":"not_attempted"`, h3Settlement.ID)) + assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"settlement_id":%d`, ordinarySettlement.ID)) + assert.Contains(t, recorder.Body.String(), `"code":"minimax_h3_required"`) + var settled model.BillingSettlement + require.NoError(t, db.First(&settled, h3Settlement.ID).Error) + assert.Equal(t, model.BillingSettlementStatusManual, settled.Status) + assert.Zero(t, settled.ReconciliationReviewedBy) + var rejected model.BillingSettlement + require.NoError(t, db.First(&rejected, ordinarySettlement.ID).Error) + assert.Equal(t, model.BillingSettlementStatusManual, rejected.Status) + var storedOwner model.User + require.NoError(t, db.First(&storedOwner, owner.Id).Error) + assert.EqualValues(t, 900, storedOwner.Quota) +} + func TestCompleteManualTaskBillingSettlementAuditsExactCompletion(t *testing.T) { gin.SetMode(gin.TestMode) oldDB := model.DB diff --git a/model/billing_settlement.go b/model/billing_settlement.go index 4757623d..e54b2aba 100644 --- a/model/billing_settlement.go +++ b/model/billing_settlement.go @@ -45,6 +45,14 @@ var ( ErrSubscriptionRefundClamped = errors.New("subscription refund was clamped") ErrSubscriptionSettlementUnbound = errors.New("subscription settlement is not bound to its pre-consume request") ErrSubscriptionSettlementPeriodChanged = errors.New("subscription settlement crossed a quota reset period") + + // SQLite permits only one writer and does not support the row-level lock + // clause used by the other supported databases. Serializing the complete + // settlement lifecycle prevents two local connections from both reading a + // pending record and then racing while upgrading their deferred transaction + // to a write transaction. The operation key remains the durable idempotency + // boundary for retries from other processes or after a restart. + billingSettlementSQLiteWriteMu sync.Mutex ) type permanentBillingSettlementError struct { @@ -202,6 +210,7 @@ type BillingSettlementReconciliationItem struct { TaskQuota int64 `json:"task_quota"` TaskQuotaTarget int64 `json:"task_quota_target"` RequiresManualCompletion bool `json:"requires_manual_completion"` + ZeroQuotaEligible bool `json:"zero_quota_eligible"` FundingDelta int64 `json:"funding_delta"` AppliedFundingDelta int64 `json:"applied_funding_delta"` TokenDelta int64 `json:"token_delta"` @@ -460,6 +469,22 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon data.Truncated = true items = items[:limit] } + taskIDs := make([]int64, 0, len(items)) + for _, item := range items { + if item.TaskID > 0 { + taskIDs = append(taskIDs, item.TaskID) + } + } + tasksByID := make(map[int64]*Task, len(taskIDs)) + if len(taskIDs) > 0 { + var tasks []Task + if err := tx.Select("id", "properties").Where("id IN ?", taskIDs).Find(&tasks).Error; err != nil { + return err + } + for index := range tasks { + tasksByID[tasks[index].ID] = &tasks[index] + } + } displayedUserIDs := make([]int, 0, len(items)) for index := range items { items[index].LastError = common.SanitizePersistedLogContent( @@ -473,6 +498,9 @@ func GetUnresolvedPositiveFinalizeSettlements(limit int) (BillingSettlementRecon FundingDelta: items[index].FundingDelta, TokenDelta: items[index].TokenDelta, TaskQuota: items[index].TaskQuota, TaskQuotaTarget: items[index].TaskQuotaTarget, }) + if items[index].RequiresManualCompletion { + items[index].ZeroQuotaEligible = IsMiniMaxH3Task(tasksByID[items[index].TaskID]) + } items[index].RecordBlocksUser = items[index].FundingDelta > 0 && strings.HasPrefix(items[index].OperationKey, billingRequestOperationPrefix) && strings.HasSuffix(items[index].OperationKey, billingRequestFinalizeSuffix) && @@ -766,6 +794,10 @@ func EnsureManualTaskBillingCompletion(input BillingSettlementInput, reviewerID if reviewerID <= 0 || strings.TrimSpace(note) == "" || !billingSettlementIsManualTaskCompletion(input.OperationKey, input.TaskID) { return BillingSettlement{}, ErrBillingSettlementReviewConflict } + if common.UsingSQLite { + billingSettlementSQLiteWriteMu.Lock() + defer billingSettlementSQLiteWriteMu.Unlock() + } var approved BillingSettlement err := DB.Transaction(func(tx *gorm.DB) error { record, _, err := ensureBillingSettlementRecordDB(tx, input) @@ -953,6 +985,10 @@ func ResolveManualTaskBillingSettlement(id int64, expectedRevision int64, review if id <= 0 || expectedRevision <= 0 || reviewerID <= 0 || strings.TrimSpace(note) == "" { return BillingSettlement{}, false, ErrBillingSettlementReviewConflict } + if common.UsingSQLite { + billingSettlementSQLiteWriteMu.Lock() + defer billingSettlementSQLiteWriteMu.Unlock() + } var resolved BillingSettlement alreadyResolved := false err := DB.Transaction(func(tx *gorm.DB) error { @@ -1054,6 +1090,10 @@ func ApplyBillingSettlementOnce(input BillingSettlementInput) (appliedFundingDel if input.OperationKey == "" { return 0, false, errors.New("billing settlement operation key is required") } + if common.UsingSQLite { + billingSettlementSQLiteWriteMu.Lock() + defer billingSettlementSQLiteWriteMu.Unlock() + } record, alreadyApplied, err := ensureBillingSettlementRecord(input) if err != nil { @@ -1253,6 +1293,10 @@ func PromoteManualTaskBillingSettlement(input BillingSettlementInput, reasonPref if err := validateBillingSettlementInput(input); err != nil { return false, err } + if common.UsingSQLite { + billingSettlementSQLiteWriteMu.Lock() + defer billingSettlementSQLiteWriteMu.Unlock() + } effectPayload, err := billingSettlementEffectPayload(input.Effect) if err != nil { return false, fmt.Errorf("%w: %v", ErrBillingSettlementRecordNotDurable, err) diff --git a/model/task.go b/model/task.go index 12629645..5be04384 100644 --- a/model/task.go +++ b/model/task.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/MAX-API-Next/MAX-API/common" @@ -72,6 +73,19 @@ type Task struct { includePrivateDataInUpdate bool `gorm:"-"` } +// IsMiniMaxH3Task reports whether the persisted task has an explicit, +// trusted MiniMax-H3 upstream binding. Client-side origin aliases and legacy +// billing-context names are intentionally not sufficient for H3-only actions. +func IsMiniMaxH3Task(task *Task) bool { + if task == nil { + return false + } + if upstream := strings.TrimSpace(task.Properties.UpstreamModelName); upstream != "" { + return strings.EqualFold(upstream, constant.TaskModelMiniMaxH3) + } + return false +} + func (t *Task) BeforeSave(*gorm.DB) error { if t != nil { t.FailReason = common.SanitizePersistedLogContent(t.FailReason) @@ -157,6 +171,7 @@ type TaskBillingContext struct { TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"` TaskBillingPlan *types.TaskBillingPlan `json:"task_billing_plan,omitempty"` TaskUsage *types.TaskUsage `json:"task_usage,omitempty"` + TaskUsageEnvelope *types.TaskUsageEnvelope `json:"task_usage_envelope,omitempty"` OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 DeltaSettlementDisabled *bool `json:"delta_settlement_disabled,omitempty"` // 渠道关闭完成态差额结算时按提交快照跳过 @@ -722,6 +737,10 @@ func (t *Task) UpdateWithStatusAndSettlementIntent(fromStatus TaskStatus, expect updatedAt = expectedUpdatedAt + 1 } + if common.UsingSQLite { + billingSettlementSQLiteWriteMu.Lock() + defer billingSettlementSQLiteWriteMu.Unlock() + } err := DB.Transaction(func(tx *gorm.DB) error { if _, _, err := ensureBillingSettlementRecordDB(tx, input); err != nil { return err @@ -779,6 +798,10 @@ func (t *Task) UpdateWithStatusAndManualSettlement(fromStatus TaskStatus, expect updatedAt = expectedUpdatedAt + 1 } + if common.UsingSQLite { + billingSettlementSQLiteWriteMu.Lock() + defer billingSettlementSQLiteWriteMu.Unlock() + } err := DB.Transaction(func(tx *gorm.DB) error { if _, err := ensureManualBillingSettlementRecordDB(tx, input, reason); err != nil { return err diff --git a/model/task_billing_contract_test.go b/model/task_billing_contract_test.go new file mode 100644 index 00000000..66225a59 --- /dev/null +++ b/model/task_billing_contract_test.go @@ -0,0 +1,409 @@ +package model + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type taskBillingContractFixture struct { + user User + token Token + channel Channel + task Task + sub UserSubscription + input BillingSettlementInput +} + +func newTaskBillingContractFixture(t *testing.T, db *gorm.DB, number int, source string, delta int64) taskBillingContractFixture { + t.Helper() + label := fmt.Sprintf("tb%04d", number) + f := taskBillingContractFixture{ + user: User{Username: label, AffCode: label, Quota: 900}, + channel: Channel{Name: label, Key: "synthetic-fixture"}, + } + taskBillingTestCheck(t, db.Create(&f.user).Error, "create fixture user") + f.token = Token{UserId: f.user.Id, Key: label, RemainQuota: 900, UsedQuota: 100} + taskBillingTestCheck(t, db.Create(&f.token).Error, "create fixture token") + taskBillingTestCheck(t, db.Create(&f.channel).Error, "create fixture channel") + zero := int64(0) + f.task = Task{TaskID: label, UserId: f.user.Id, ChannelId: f.channel.Id, Quota: 100, Status: TaskStatusInProgress, UpdatedAt: 1, + PrivateData: TaskPrivateData{BillingContext: &TaskBillingContext{ + OriginModelName: "contract-model", + TaskUsageEnvelope: &types.TaskUsageEnvelope{SchemaVersion: 1, SourceID: "minimax", + Usage: &types.TaskUsage{OutputDurationMs: &zero}}, + }}, + } + taskBillingTestCheck(t, db.Create(&f.task).Error, "create fixture task") + f.input = BillingSettlementInput{ + OperationKey: BillingTaskFinalizeOperationKey(f.task.ID), Source: source, UserID: f.user.Id, + TokenID: f.token.Id, FundingDelta: delta, TokenDelta: delta, TaskID: f.task.ID, TaskQuota: 100, TaskQuotaTarget: 100 + delta, + Effect: &BillingSettlementEffect{LogType: LogTypeConsume, ModelName: "contract-model", TokenID: f.token.Id, + ChannelID: f.channel.Id, Quota: 100 + delta, QuotaIsActual: true, UpdateUsage: true, PromptTokens: 2, CompletionTokens: 3}, + } + if source == BillingSettlementSourceSubscription { + f.sub = UserSubscription{UserId: f.user.Id, AmountTotal: 1000, AmountUsed: 100, Status: "active", + StartTime: 1, EndTime: time.Now().Add(time.Hour).Unix(), LastResetTime: 1} + taskBillingTestCheck(t, db.Create(&f.sub).Error, "create fixture subscription") + pre := SubscriptionPreConsumeRecord{RequestId: label, UserId: f.user.Id, TokenId: f.token.Id, + UserSubscriptionId: f.sub.Id, SubscriptionLastResetTime: 1, PreConsumed: 100, Status: "consumed"} + taskBillingTestCheck(t, db.Create(&pre).Error, "create fixture preconsume") + f.input.SubscriptionID, f.input.SubscriptionPreConsumeRequestID = f.sub.Id, pre.RequestId + } + return f +} + +func (f taskBillingContractFixture) assertBalances(t *testing.T, db *gorm.DB, delta int64) { + t.Helper() + var user User + var token Token + var task Task + taskBillingTestCheck(t, db.First(&user, f.user.Id).Error, "read wallet") + taskBillingTestCheck(t, db.First(&token, f.token.Id).Error, "read token") + taskBillingTestCheck(t, db.First(&task, f.task.ID).Error, "read task") + wallet := int64(900) + if f.input.Source == BillingSettlementSourceWallet { + wallet -= delta + } else { + var sub UserSubscription + taskBillingTestCheck(t, db.First(&sub, f.sub.Id).Error, "read subscription") + require.Equal(t, 100+delta, sub.AmountUsed) + } + require.Equal(t, wallet, user.Quota) + require.Equal(t, 900-delta, token.RemainQuota) + require.Equal(t, 100+delta, token.UsedQuota) + require.EqualValues(t, 100+delta, task.Quota) + // Funding/effect execution must not replace the provider-driven lifecycle. + require.Equal(t, TaskStatus(TaskStatusInProgress), task.Status) + require.NotNil(t, task.PrivateData.BillingContext) + require.NotNil(t, task.PrivateData.BillingContext.TaskUsageEnvelope) + usage := task.PrivateData.BillingContext.TaskUsageEnvelope.Usage + require.NotNil(t, usage) + require.NotNil(t, usage.OutputDurationMs) + require.Zero(t, *usage.OutputDurationMs) +} + +func (f taskBillingContractFixture) assertProjection(t *testing.T, db *gorm.DB, quota int64, count int) { + t.Helper() + var user User + var channel Channel + var logs []Log + var receipts int64 + taskBillingTestCheck(t, db.First(&user, f.user.Id).Error, "read usage counters") + taskBillingTestCheck(t, db.First(&channel, f.channel.Id).Error, "read channel usage") + taskBillingTestCheck(t, db.Where("user_id = ?", f.user.Id).Find(&logs).Error, "read consume logs") + taskBillingTestCheck(t, db.Model(&BillingLogReceipt{}).Where("operation_key = ?", f.input.OperationKey).Count(&receipts).Error, "read log receipt") + require.Equal(t, quota, user.UsedQuota) + require.Equal(t, quota, channel.UsedQuota) + require.Equal(t, count, user.RequestCount) + require.Len(t, logs, count) + require.EqualValues(t, count, receipts) + if count == 1 { + require.EqualValues(t, quota, logs[0].Quota) + require.Equal(t, 2, logs[0].PromptTokens) + require.Equal(t, 3, logs[0].CompletionTokens) + } +} + +func taskBillingContractRecord(t *testing.T, db *gorm.DB, key string) BillingSettlement { + t.Helper() + var records []BillingSettlement + taskBillingTestCheck(t, db.Where("operation_key = ?", key).Find(&records).Error, "read durable settlement") + require.Len(t, records, 1, "one stable operation identity must own the settlement") + return records[0] +} + +func runTaskBillingSettlementContracts(t *testing.T, db *gorm.DB) { + t.Helper() + sequence := 0 + fixture := func(t *testing.T, source string, delta int64) taskBillingContractFixture { + sequence++ + return newTaskBillingContractFixture(t, db, sequence, source, delta) + } + for _, source := range []string{BillingSettlementSourceWallet, BillingSettlementSourceSubscription} { + for _, delta := range []int64{-100, -20, 0, 20} { + t.Run(fmt.Sprintf("%s/delta_%d", source, delta), func(t *testing.T) { + f := fixture(t, source, delta) + for attempt := 0; attempt < 2; attempt++ { + applied, replay, err := ApplyBillingSettlementOnce(f.input) + taskBillingTestCheck(t, err, "apply funding") + require.Equal(t, delta, applied) + require.Equal(t, attempt > 0, replay) + } + f.assertBalances(t, db, delta) + f.assertProjection(t, db, 0, 0) + record := taskBillingContractRecord(t, db, f.input.OperationKey) + require.Equal(t, BillingSettlementStatusApplied, record.Status) + require.Equal(t, BillingSettlementEffectPending, record.EffectStatus) + require.Equal(t, delta, record.AppliedFundingDelta) + require.Equal(t, delta, record.AppliedTokenDelta) + changed := f.input + changed.FundingDelta++ + changed.TokenDelta++ + changed.TaskQuotaTarget++ + _, _, err := ApplyBillingSettlementOnce(changed) + require.True(t, errors.Is(err, ErrBillingSettlementOperationConflict), "changed frozen amount must be rejected") + for attempt := 0; attempt < 2; attempt++ { + taskBillingTestCheck(t, ProcessBillingSettlementEffect(f.input.OperationKey), "replay effect") + } + f.assertBalances(t, db, delta) + f.assertProjection(t, db, 100+delta, 1) + require.Equal(t, BillingSettlementEffectApplied, taskBillingContractRecord(t, db, f.input.OperationKey).EffectStatus) + }) + } + } + + t.Run("concurrent_duplicate_funding", func(t *testing.T) { + sqlDB, err := db.DB() + taskBillingTestCheck(t, err, "access SQLite connection pool") + previousMaxOpenConns := sqlDB.Stats().MaxOpenConnections + if previousMaxOpenConns < 2 { + sqlDB.SetMaxOpenConns(4) + t.Cleanup(func() { sqlDB.SetMaxOpenConns(previousMaxOpenConns) }) + } + require.GreaterOrEqual(t, sqlDB.Stats().MaxOpenConnections, 2, + "the duplicate-settlement contract must exercise multiple database connections") + f := fixture(t, BillingSettlementSourceWallet, -20) + type result struct { + delta int64 + replay bool + err error + } + results := make(chan result, 2) + start := make(chan struct{}) + for i := 0; i < 2; i++ { + go func() { + <-start + delta, replay, err := ApplyBillingSettlementOnce(f.input) + results <- result{delta, replay, err} + }() + } + close(start) + // Join both workers before assertions/cleanup can restore package globals. + first, second := <-results, <-results + for _, got := range []result{first, second} { + taskBillingTestCheck(t, got.err, "concurrent funding") + require.EqualValues(t, -20, got.delta) + } + require.NotEqual(t, first.replay, second.replay) + f.assertBalances(t, db, -20) + require.Equal(t, BillingSettlementStatusApplied, taskBillingContractRecord(t, db, f.input.OperationKey).Status) + }) + + t.Run("concurrent_settlement_intent", func(t *testing.T) { + if !common.UsingSQLite { + t.Skip("SQLite-specific settlement intent serialization") + } + sqlDB, err := db.DB() + taskBillingTestCheck(t, err, "access database connection pool") + previousMaxOpenConns := sqlDB.Stats().MaxOpenConnections + if previousMaxOpenConns < 2 { + sqlDB.SetMaxOpenConns(4) + t.Cleanup(func() { sqlDB.SetMaxOpenConns(previousMaxOpenConns) }) + } + require.GreaterOrEqual(t, sqlDB.Stats().MaxOpenConnections, 2, + "the concurrent intent contract must exercise multiple database connections") + + for _, manual := range []bool{false, true} { + t.Run(map[bool]string{false: "pending", true: "manual"}[manual], func(t *testing.T) { + f := fixture(t, BillingSettlementSourceWallet, -20) + input := f.input + if manual { + input.FundingDelta = 0 + input.TokenDelta = 0 + input.TaskQuotaTarget = input.TaskQuota + input.Effect = nil + } + + first, second := f.task, f.task + type result struct { + won bool + err error + } + results := make(chan result, 2) + start := make(chan struct{}) + call := func(task Task) { + <-start + var won bool + var err error + if manual { + won, err = task.UpdateWithStatusAndManualSettlement( + TaskStatusInProgress, + f.task.UpdatedAt, + input, + "provider usage is missing", + ) + } else { + won, err = task.UpdateWithStatusAndSettlementIntent( + TaskStatusInProgress, + f.task.UpdatedAt, + input, + ) + } + results <- result{won: won, err: err} + } + go call(first) + go call(second) + close(start) + firstResult, secondResult := <-results, <-results + + for _, got := range []result{firstResult, secondResult} { + taskBillingTestCheck(t, got.err, "concurrent settlement intent") + } + require.NotEqual(t, firstResult.won, secondResult.won, + "exactly one poller must win the task CAS") + record := taskBillingContractRecord(t, db, input.OperationKey) + if manual { + require.Equal(t, BillingSettlementStatusManual, record.Status) + } else { + require.Equal(t, BillingSettlementStatusPending, record.Status) + } + var count int64 + taskBillingTestCheck(t, db.Model(&BillingSettlement{}). + Where("operation_key = ?", input.OperationKey).Count(&count).Error, + "count concurrent settlement intents") + require.EqualValues(t, 1, count) + }) + } + }) + + t.Run("cas_winner_stale_loser", func(t *testing.T) { + f := fixture(t, BillingSettlementSourceWallet, -20) + stale := f.task + won, err := f.task.UpdateWithStatusAndSettlementIntent(TaskStatusInProgress, 1, f.input) + taskBillingTestCheck(t, err, "CAS winner") + require.True(t, won) + won, err = stale.UpdateWithStatusAndSettlementIntent(TaskStatusInProgress, 1, f.input) + taskBillingTestCheck(t, err, "CAS loser") + require.False(t, won) + f.assertBalances(t, db, 0) + require.Equal(t, BillingSettlementStatusPending, taskBillingContractRecord(t, db, f.input.OperationKey).Status) + }) + + for _, source := range []string{BillingSettlementSourceWallet, BillingSettlementSourceSubscription} { + t.Run(source+"/manual_promotion_replay", func(t *testing.T) { + f := fixture(t, source, -20) + manual := f.input + manual.FundingDelta, manual.TokenDelta, manual.TaskQuotaTarget, manual.Effect = 0, 0, 100, nil + const reason = "H3 terminal usage requires manual reconciliation:" + won, err := f.task.UpdateWithStatusAndManualSettlement(TaskStatusInProgress, 1, manual, reason+" missing") + taskBillingTestCheck(t, err, "persist manual intent") + require.True(t, won) + require.Equal(t, BillingSettlementStatusManual, taskBillingContractRecord(t, db, f.input.OperationKey).Status) + for i := 0; i < 2; i++ { + ready, err := PromoteManualTaskBillingSettlement(f.input, reason, "synthetic complete evidence") + taskBillingTestCheck(t, err, "promote manual intent") + require.True(t, ready) + } + f.assertBalances(t, db, 0) + require.Equal(t, BillingSettlementStatusPending, taskBillingContractRecord(t, db, f.input.OperationKey).Status) + _, _, err = ApplyBillingSettlementOnce(f.input) + taskBillingTestCheck(t, err, "apply promoted intent") + taskBillingTestCheck(t, ProcessBillingSettlementEffect(f.input.OperationKey), "apply promoted effect") + f.assertBalances(t, db, -20) + f.assertProjection(t, db, 80, 1) + }) + } + + t.Run("funding_transaction_rollback_and_retry", func(t *testing.T) { + f := fixture(t, BillingSettlementSourceWallet, -20) + injected := errors.New("injected token update failure") + callback := "test:task-billing-token-failure" + removed := false + taskBillingTestCheck(t, db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Table == "tokens" { + _ = tx.AddError(injected) + } + }), "register transaction fault") + t.Cleanup(func() { + if !removed { + taskBillingTestCheck(t, db.Callback().Update().Remove(callback), "remove transaction fault") + } + }) + _, _, err := ApplyBillingSettlementOnce(f.input) + require.True(t, errors.Is(err, injected)) + f.assertBalances(t, db, 0) + f.assertProjection(t, db, 0, 0) + require.Equal(t, BillingSettlementStatusPending, taskBillingContractRecord(t, db, f.input.OperationKey).Status) + taskBillingTestCheck(t, db.Callback().Update().Remove(callback), "disable transaction fault") + removed = true + _, replay, err := ApplyBillingSettlementOnce(f.input) + taskBillingTestCheck(t, err, "retry rolled back funding") + require.False(t, replay) + f.assertBalances(t, db, -20) + }) + + t.Run("effect_failure_after_log_and_retry", func(t *testing.T) { + f := fixture(t, BillingSettlementSourceWallet, -100) + _, _, err := ApplyBillingSettlementOnce(f.input) + taskBillingTestCheck(t, err, "apply zero final funding") + injected := errors.New("injected usage projection failure") + callback := "test:task-billing-effect-failure" + removed := false + taskBillingTestCheck(t, db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Table == "users" { + _ = tx.AddError(injected) + } + }), "register effect fault") + t.Cleanup(func() { + if !removed { + taskBillingTestCheck(t, db.Callback().Update().Remove(callback), "remove effect fault") + } + }) + err = ProcessBillingSettlementEffect(f.input.OperationKey) + require.True(t, errors.Is(err, injected)) + record := taskBillingContractRecord(t, db, f.input.OperationKey) + require.Equal(t, BillingSettlementStatusApplied, record.Status) + require.Equal(t, BillingSettlementEffectPending, record.EffectStatus) + f.assertBalances(t, db, -100) + var user User + taskBillingTestCheck(t, db.First(&user, f.user.Id).Error, "read rolled back projection") + require.Zero(t, user.RequestCount) + var logs int64 + taskBillingTestCheck(t, db.Model(&Log{}).Where("user_id = ?", f.user.Id).Count(&logs).Error, "read committed log") + require.EqualValues(t, 1, logs) + taskBillingTestCheck(t, db.Callback().Update().Remove(callback), "disable effect fault") + removed = true + for i := 0; i < 2; i++ { + taskBillingTestCheck(t, ProcessBillingSettlementEffect(f.input.OperationKey), "recover effect") + } + f.assertBalances(t, db, -100) + f.assertProjection(t, db, 0, 1) + }) +} + +// Guard parsing is tested without a live server or any user credentials. +func TestTaskBillingExternalDatabaseGuard(t *testing.T) { + for _, test := range []struct { + name, dialect, dsn string + safe bool + }{ + {"mysql_safe", "mysql", "fixture@tcp(127.0.0.1:3306)/maxapi_task_billing_test_fixture?tls=true", true}, + {"mysql_unix_socket", "mysql", "fixture@unix(/var/run/mysqld/mysqld.sock)/maxapi_task_billing_test_fixture", true}, + {"mysql_plaintext", "mysql", "fixture@tcp(127.0.0.1:3306)/maxapi_task_billing_test_fixture", false}, + {"postgres_safe", "postgres", "postgres://fixture@127.0.0.1/maxapi_task_billing_test_fixture?sslmode=verify-full", true}, + {"postgres_plaintext_fallback", "postgres", "postgres://fixture@127.0.0.1/maxapi_task_billing_test_fixture?sslmode=prefer", false}, + {"mysql_wrong_database", "mysql", "maxapi_task_billing_test_fixture@tcp(127.0.0.1:3306)/production", false}, + {"postgres_wrong_database", "postgres", "postgres://maxapi_task_billing_test_fixture@127.0.0.1/production", false}, + {"mysql_no_database", "mysql", "fixture@tcp(127.0.0.1:3306)/", false}, + {"unknown_dialect", "other", "", false}, + } { + t.Run(test.name, func(t *testing.T) { + db, name, err := openTaskBillingTestSQL(test.dialect, test.dsn) + if test.safe { + taskBillingTestCheck(t, err, "parse synthetic DSN") + require.Equal(t, "maxapi_task_billing_test_fixture", name) + taskBillingTestCheck(t, db.Close(), "close unconnected guard fixture") + } else { + require.True(t, errors.Is(err, errTaskBillingUnsafeDatabase)) + require.Nil(t, db) + } + }) + } +} diff --git a/model/task_billing_external_test.go b/model/task_billing_external_test.go new file mode 100644 index 00000000..cf9076e8 --- /dev/null +++ b/model/task_billing_external_test.go @@ -0,0 +1,234 @@ +package model + +import ( + "context" + "database/sql" + "errors" + "flag" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/glebarez/sqlite" + mysqldriver "github.com/go-sql-driver/mysql" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" + gormmysql "gorm.io/driver/mysql" + gormpostgres "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Package DB handles are global: never use t.Parallel while this fixture is +// active. Only workers within a contract may concurrently use the fixed handle. +func TestTaskBillingSettlementContractSQLite(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "settlement.db")), taskBillingTestGORMConfig()) + taskBillingTestCheck(t, err, "open SQLite") + sqlDB, err := db.DB() + taskBillingTestCheck(t, err, "access SQLite connection") + t.Cleanup(func() { taskBillingTestCheck(t, sqlDB.Close(), "close SQLite") }) + sqlDB.SetMaxOpenConns(1) + prepareTaskBillingContractDB(t, db, "sqlite") + runTaskBillingSettlementContracts(t, db) +} + +func TestTaskBillingSettlementContractMySQL(t *testing.T) { + runExternalTaskBillingSettlementContract(t, "mysql", "TEST_TASK_BILLING_MYSQL_DSN") +} + +func TestTaskBillingSettlementContractPostgreSQL(t *testing.T) { + runExternalTaskBillingSettlementContract(t, "postgres", "TEST_TASK_BILLING_POSTGRES_DSN") +} + +var taskBillingTestDatabaseName = regexp.MustCompile(`^maxapi_task_billing_test_[a-z0-9_]{1,32}$`) +var errTaskBillingUnsafeDatabase = errors.New("dedicated disposable task-billing database required") +var taskBillingExternalEnabled = flag.Bool("task-billing-external", false, "run the task-billing contract against dedicated disposable databases") + +func taskBillingTestGORMConfig() *gorm.Config { + // Connection errors and SQL literals can contain credentials. Never emit + // them from the external harness, even on connection/migration failure. + return &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)} +} + +func taskBillingTestCheck(t *testing.T, err error, stage string) { + t.Helper() + if err != nil { + t.Fatalf("task-billing contract stage %s failed (error details withheld)", stage) + } +} + +// Each external invocation needs a freshly provisioned disposable database. +// Nothing is dropped: successful and partial migrations are retained for audit. +// The unique ownership table rejects a second harness targeting the same DB. +type taskBillingTestOwnership struct { + ID int +} + +func (taskBillingTestOwnership) TableName() string { return "task_billing_test_ownership" } + +func openTaskBillingTestSQL(dialect, dsn string) (*sql.DB, string, error) { + switch dialect { + case "mysql": + config, err := mysqldriver.ParseDSN(dsn) + if err != nil { + return nil, "", err + } + if !taskBillingTestDatabaseName.MatchString(config.DBName) { + return nil, "", errTaskBillingUnsafeDatabase + } + // External contract tests may never put database credentials on a + // plaintext network connection. Unix sockets are local-only; every + // other network type (including tcp4/tcp6) requires certificate- + // validating TLS and must not permit plaintext fallback. + if config.Net != "unix" && (config.TLS == nil || config.TLS.InsecureSkipVerify || config.AllowFallbackToPlaintext) { + return nil, "", errTaskBillingUnsafeDatabase + } + config.Timeout, config.ReadTimeout, config.WriteTimeout = 5*time.Second, 10*time.Second, 10*time.Second + connector, err := mysqldriver.NewConnector(config) + if err != nil { + return nil, "", err + } + return sql.OpenDB(connector), config.DBName, nil + case "postgres": + config, err := pgx.ParseConfig(dsn) + if err != nil { + return nil, "", err + } + if !taskBillingTestDatabaseName.MatchString(config.Database) { + return nil, "", errTaskBillingUnsafeDatabase + } + sslMode := taskBillingPostgresSSLMode(dsn) + if sslMode != "verify-ca" && sslMode != "verify-full" && + !(sslMode == "require" && config.TLSConfig != nil && config.TLSConfig.VerifyPeerCertificate != nil) { + return nil, "", errTaskBillingUnsafeDatabase + } + if config.TLSConfig == nil { + return nil, "", errTaskBillingUnsafeDatabase + } + for _, fallback := range config.Fallbacks { + if fallback.TLSConfig == nil { + return nil, "", errTaskBillingUnsafeDatabase + } + } + config.ConnectTimeout = 5 * time.Second + config.RuntimeParams["search_path"] = "public" + config.RuntimeParams["statement_timeout"] = "10000" + config.RuntimeParams["lock_timeout"] = "5000" + return stdlib.OpenDB(*config), config.Database, nil + default: + return nil, "", errTaskBillingUnsafeDatabase + } +} + +func TestOpenTaskBillingTestSQLRejectsPlaintextNetworkVariants(t *testing.T) { + for _, network := range []string{"tcp", "tcp4", "tcp6"} { + t.Run(network, func(t *testing.T) { + _, _, err := openTaskBillingTestSQL( + "mysql", + fmt.Sprintf("user:password@%s(127.0.0.1:3306)/maxapi_task_billing_test_tls", network), + ) + if !errors.Is(err, errTaskBillingUnsafeDatabase) { + t.Fatalf("expected plaintext %s DSN to be rejected", network) + } + }) + } +} + +func taskBillingPostgresSSLMode(dsn string) string { + if parsed, err := url.Parse(dsn); err == nil && parsed.Scheme != "" { + return strings.ToLower(strings.TrimSpace(parsed.Query().Get("sslmode"))) + } + for _, field := range strings.Fields(dsn) { + parts := strings.SplitN(field, "=", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "sslmode") { + return strings.ToLower(strings.Trim(parts[1], "\"'")) + } + } + return "" +} + +func runExternalTaskBillingSettlementContract(t *testing.T, dialect, envName string) { + t.Helper() + if !*taskBillingExternalEnabled { + t.Skip("external contract requires -task-billing-external; NOT RUN") + } + dsn := strings.TrimSpace(os.Getenv(envName)) + if dsn == "" { + t.Skip("dedicated " + envName + " is not set; external contract NOT RUN") + } + sqlDB, expectedName, err := openTaskBillingTestSQL(dialect, dsn) + taskBillingTestCheck(t, err, "validate dedicated "+dialect+" DSN and test database name") + t.Cleanup(func() { taskBillingTestCheck(t, sqlDB.Close(), "close "+dialect) }) + sqlDB.SetMaxOpenConns(4) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + taskBillingTestCheck(t, sqlDB.PingContext(ctx), "connect "+dialect) + var name string + var relations int + if dialect == "mysql" { + taskBillingTestCheck(t, sqlDB.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&name), "verify MySQL target") + taskBillingTestCheck(t, sqlDB.QueryRowContext(ctx, + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()").Scan(&relations), "inspect MySQL tables and views") + } else { + taskBillingTestCheck(t, sqlDB.QueryRowContext(ctx, "SELECT current_database()").Scan(&name), "verify PostgreSQL target") + // Inspect every user schema, not only the search_path. This includes + // tables, views, sequences and materialized views. + taskBillingTestCheck(t, sqlDB.QueryRowContext(ctx, + "SELECT COUNT(*) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname NOT LIKE 'pg_%' AND n.nspname <> 'information_schema'").Scan(&relations), "inspect PostgreSQL user relations") + } + if name != expectedName || relations != 0 { + t.Fatal("external target identity mismatch or nonempty database; no migration performed") + } + + var dialector gorm.Dialector + if dialect == "mysql" { + dialector = gormmysql.New(gormmysql.Config{Conn: sqlDB}) + } else { + dialector = gormpostgres.New(gormpostgres.Config{Conn: sqlDB}) + } + db, err := gorm.Open(dialector, taskBillingTestGORMConfig()) + taskBillingTestCheck(t, err, "initialize "+dialect+" GORM") + taskBillingTestCheck(t, db.Migrator().CreateTable(&taskBillingTestOwnership{}), "claim exclusive external test ownership") + t.Log("external schema and synthetic data are retained for audit; use a fresh disposable database for the next invocation") + prepareTaskBillingContractDB(t, db, dialect) + runTaskBillingSettlementContracts(t, db) +} + +func prepareTaskBillingContractDB(t *testing.T, db *gorm.DB, dialect string) { + t.Helper() + // Create only the real models required by this seam. Do not call shared + // truncateTables or application startup migrations against an external DB. + for _, model := range []any{&User{}, &Token{}, &Channel{}, &Task{}, &BillingSettlement{}, + &Log{}, &BillingLogReceipt{}, &UserSubscription{}, &SubscriptionPreConsumeRecord{}} { + taskBillingTestCheck(t, db.Migrator().CreateTable(model), "create contract schema") + } + + oldDB, oldLogDB := DB, LOG_DB + oldSQLite, oldMySQL, oldPostgres := common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL + oldRedis, oldBatch, oldExport := common.RedisEnabled, common.BatchUpdateEnabled, common.DataExportEnabled + oldConsume, oldMemory := common.LogConsumeEnabled, common.MemoryCacheEnabled + oldGroup, oldKey, oldTrue, oldFalse := commonGroupCol, commonKeyCol, commonTrueVal, commonFalseVal + oldLogGroup, oldLogKey := logGroupCol, logKeyCol + DB, LOG_DB = db, db + common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL = dialect == "sqlite", dialect == "mysql", dialect == "postgres" + common.RedisEnabled, common.BatchUpdateEnabled, common.DataExportEnabled = false, false, false + common.LogConsumeEnabled, common.MemoryCacheEnabled = true, false + initCol() + // This fixture uses one DB for both handles regardless of LOG_SQL_DSN. + logGroupCol, logKeyCol = commonGroupCol, commonKeyCol + t.Cleanup(func() { + DB, LOG_DB = oldDB, oldLogDB + common.UsingSQLite, common.UsingMySQL, common.UsingPostgreSQL = oldSQLite, oldMySQL, oldPostgres + common.RedisEnabled, common.BatchUpdateEnabled, common.DataExportEnabled = oldRedis, oldBatch, oldExport + common.LogConsumeEnabled, common.MemoryCacheEnabled = oldConsume, oldMemory + commonGroupCol, commonKeyCol, commonTrueVal, commonFalseVal = oldGroup, oldKey, oldTrue, oldFalse + logGroupCol, logKeyCol = oldLogGroup, oldLogKey + }) +} diff --git a/model/task_manual_settlement_test.go b/model/task_manual_settlement_test.go index 01d67cdd..59d8fc3c 100644 --- a/model/task_manual_settlement_test.go +++ b/model/task_manual_settlement_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/MAX-API-Next/MAX-API/types" "github.com/stretchr/testify/require" "gorm.io/gorm" ) @@ -184,6 +185,52 @@ func TestUpdateWithStatusAndSettlementIntentKeepsTaskNonTerminal(t *testing.T) { require.EqualValues(t, -300, settlement.FundingDelta) } +func TestTaskPrivateDataPersistsTaskUsageEnvelope(t *testing.T) { + truncateTables(t) + zero := int64(0) + task := &Task{ + TaskID: "usage-envelope-round-trip", + Status: TaskStatusInProgress, + PrivateData: TaskPrivateData{BillingContext: &TaskBillingContext{ + TaskUsage: &types.TaskUsage{ + OutputDurationMs: &zero, + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessPartial, + }, + TaskUsageEnvelope: &types.TaskUsageEnvelope{ + ProducerKind: types.TaskUsageProducerKindGoAdapter, + SourceID: "minimax", SchemaVersion: 1, + ContractDigest: "contract-digest", Stage: types.TaskUsageSourceProviderResponse, + Presence: types.TaskUsagePresencePartial, Completeness: types.TaskUsageCompletenessPartial, + EvidenceDigest: "evidence-digest", + Usage: &types.TaskUsage{ + OutputDurationMs: &zero, + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessPartial, + }, + }, + }}, + } + insertTask(t, task) + + var stored Task + require.NoError(t, DB.First(&stored, task.ID).Error) + require.NotNil(t, stored.PrivateData.BillingContext) + envelope := stored.PrivateData.BillingContext.TaskUsageEnvelope + require.NotNil(t, envelope) + require.Equal(t, types.TaskUsageProducerKindGoAdapter, envelope.ProducerKind) + require.Equal(t, "minimax", envelope.SourceID) + require.Equal(t, 1, envelope.SchemaVersion) + require.Equal(t, "contract-digest", envelope.ContractDigest) + require.Equal(t, types.TaskUsageSourceProviderResponse, envelope.Stage) + require.Equal(t, types.TaskUsagePresencePartial, envelope.Presence) + require.Equal(t, types.TaskUsageCompletenessPartial, envelope.Completeness) + require.Equal(t, "evidence-digest", envelope.EvidenceDigest) + require.NotNil(t, envelope.Usage) + require.NotNil(t, envelope.Usage.OutputDurationMs) + require.Zero(t, *envelope.Usage.OutputDurationMs) + require.Equal(t, types.TaskUsageSourceProviderResponse, envelope.Usage.Source) + require.Equal(t, types.TaskUsageCompletenessPartial, envelope.Usage.Completeness) +} + func TestUpdateWithStatusAndSettlementIntentCASLossLeavesNoIntent(t *testing.T) { truncateTables(t) task := &Task{TaskID: "pending-h3-cas-loss", Status: TaskStatusQueued, Quota: 800} diff --git a/pkg/taskusage/taskusage.go b/pkg/taskusage/taskusage.go new file mode 100644 index 00000000..49c13b7d --- /dev/null +++ b/pkg/taskusage/taskusage.go @@ -0,0 +1,377 @@ +package taskusage + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/MAX-API-Next/MAX-API/types" +) + +const ( + SourceIDMiniMax = "minimax" + SourceIDDoubaoVideo = "doubao_video" +) + +// legacyEvidenceFieldOrder is the field order used by envelopes emitted before +// evidence digests were scoped to the frozen contract. Keep it unchanged so +// historical in-flight tasks remain verifiable after future schema additions. +var legacyEvidenceFieldOrder = [...]string{ + types.TaskUsageFieldOutputDurationMs, + types.TaskUsageFieldInputVideoDurationMs, + types.TaskUsageFieldInputAudioDurationMs, + types.TaskUsageFieldInputImageCount, + types.TaskUsageFieldInputVideoCount, + types.TaskUsageFieldInputAudioCount, + types.TaskUsageFieldCompletionTokens, + types.TaskUsageFieldTotalTokens, +} + +var canonicalFieldUnits = map[string]string{ + types.TaskUsageFieldOutputDurationMs: types.TaskUsageUnitMillisecond, + types.TaskUsageFieldInputVideoDurationMs: types.TaskUsageUnitMillisecond, + types.TaskUsageFieldInputAudioDurationMs: types.TaskUsageUnitMillisecond, + types.TaskUsageFieldInputImageCount: types.TaskUsageUnitCount, + types.TaskUsageFieldInputVideoCount: types.TaskUsageUnitCount, + types.TaskUsageFieldInputAudioCount: types.TaskUsageUnitCount, + types.TaskUsageFieldCompletionTokens: types.TaskUsageUnitToken, + types.TaskUsageFieldTotalTokens: types.TaskUsageUnitToken, +} + +// MiniMaxH3Contract returns a copy of the host-owned H3 usage schema. Pricing +// and request-conditional requirements remain in the frozen billing plan; this +// contract only bounds facts that the adaptor may produce. +func MiniMaxH3Contract() types.TaskUsageContract { + return types.TaskUsageContract{ + SourceID: SourceIDMiniMax, + SchemaVersion: 1, + Fields: []types.TaskUsageFieldContract{ + usageField(types.TaskUsageFieldOutputDurationMs, types.TaskUsageUnitMillisecond, true, 4_000, int64Pointer(15_000)), + usageField(types.TaskUsageFieldInputVideoDurationMs, types.TaskUsageUnitMillisecond, false, 0, int64Pointer(15_000)), + usageField(types.TaskUsageFieldInputAudioDurationMs, types.TaskUsageUnitMillisecond, false, 0, int64Pointer(15_000)), + usageField(types.TaskUsageFieldInputImageCount, types.TaskUsageUnitCount, true, 0, int64Pointer(9)), + }, + } +} + +// DoubaoVideoContract describes only the token facts already exposed by the +// current fixed Go adaptor. It does not select a price or authorize settlement. +func DoubaoVideoContract() types.TaskUsageContract { + return types.TaskUsageContract{ + SourceID: SourceIDDoubaoVideo, + SchemaVersion: 1, + Fields: []types.TaskUsageFieldContract{ + usageField(types.TaskUsageFieldCompletionTokens, types.TaskUsageUnitToken, true, 0, nil), + usageField(types.TaskUsageFieldTotalTokens, types.TaskUsageUnitToken, true, 0, nil), + }, + } +} + +// ResolveContract returns the host-owned contract frozen by a task plan. +// Prior versions must remain registered so in-flight tasks are never +// reinterpreted under a newer usage schema. +func ResolveContract(sourceID string, schemaVersion int, contractDigest string) (types.TaskUsageContract, error) { + var contract types.TaskUsageContract + switch { + case sourceID == SourceIDMiniMax && schemaVersion == 1: + contract = MiniMaxH3Contract() + case sourceID == SourceIDDoubaoVideo && schemaVersion == 1: + contract = DoubaoVideoContract() + default: + return types.TaskUsageContract{}, fmt.Errorf("unsupported task usage contract %q version %d", sourceID, schemaVersion) + } + digest, err := ContractDigest(contract) + if err != nil { + return types.TaskUsageContract{}, err + } + if contractDigest == "" || contractDigest != digest { + return types.TaskUsageContract{}, fmt.Errorf("task usage contract digest mismatch") + } + return contract, nil +} + +func usageField(key, unit string, required bool, minValue int64, maxValue *int64) types.TaskUsageFieldContract { + return types.TaskUsageFieldContract{ + Key: key, Unit: unit, RequiredForTerminal: required, + MinValue: int64Pointer(minValue), MaxValue: maxValue, + } +} + +func int64Pointer(value int64) *int64 { + return &value +} + +// ContractDigest returns a deterministic digest independent of field order. +func ContractDigest(contract types.TaskUsageContract) (string, error) { + if err := validateContract(contract); err != nil { + return "", err + } + fields := append([]types.TaskUsageFieldContract(nil), contract.Fields...) + sort.Slice(fields, func(i, j int) bool { return fields[i].Key < fields[j].Key }) + var canonical strings.Builder + fmt.Fprintf(&canonical, "source_id=%s\nschema_version=%d\n", contract.SourceID, contract.SchemaVersion) + for _, field := range fields { + fmt.Fprintf(&canonical, "field=%s\nunit=%s\nrequired=%t\nmin=%s\nmax=%s\n", + field.Key, field.Unit, field.RequiredForTerminal, pointerString(field.MinValue), pointerString(field.MaxValue)) + } + return sha256Hex(canonical.String()), nil +} + +// BuildEnvelope validates canonical facts and binds them to a host-owned +// contract. The raw payload is intentionally absent, so digests cannot leak +// provider URLs, prompts, credentials, or response metadata. +func BuildEnvelope(producerKind string, contract types.TaskUsageContract, stage string, usage *types.TaskUsage) (*types.TaskUsageEnvelope, error) { + if producerKind != types.TaskUsageProducerKindGoAdapter && producerKind != types.TaskUsageProducerKindTaskPlugin { + return nil, fmt.Errorf("unsupported task usage producer kind %q", producerKind) + } + if !validStage(stage) { + return nil, fmt.Errorf("unsupported task usage stage %q", stage) + } + contractDigest, err := ContractDigest(contract) + if err != nil { + return nil, err + } + cloned := types.CloneTaskUsage(usage) + completeness := types.TaskUsageCompletenessMissing + if cloned != nil { + if cloned.Source != stage { + return nil, fmt.Errorf("task usage source %q does not match stage %q", cloned.Source, stage) + } + if !validCompleteness(cloned.Completeness) { + return nil, fmt.Errorf("unsupported task usage completeness %q", cloned.Completeness) + } + completeness = cloned.Completeness + } + presence, err := validateUsage(contract, cloned, completeness) + if err != nil { + return nil, err + } + envelope := &types.TaskUsageEnvelope{ + ProducerKind: producerKind, + SourceID: contract.SourceID, + SchemaVersion: contract.SchemaVersion, + ContractDigest: contractDigest, + Stage: stage, + Presence: presence, + Completeness: completeness, + Usage: cloned, + } + envelope.EvidenceDigest = evidenceDigest(contract, stage, presence, completeness, cloned) + return envelope, nil +} + +// ValidateEnvelope detects identity, presence, fact, and digest drift before a +// caller can use the envelope as settlement evidence. +func ValidateEnvelope(contract types.TaskUsageContract, envelope *types.TaskUsageEnvelope, expectedProducerKind string) error { + if envelope == nil { + return fmt.Errorf("task usage envelope is required") + } + if expectedProducerKind != types.TaskUsageProducerKindGoAdapter && expectedProducerKind != types.TaskUsageProducerKindTaskPlugin { + return fmt.Errorf("unsupported expected task usage producer kind %q", expectedProducerKind) + } + if envelope.ProducerKind != expectedProducerKind { + return fmt.Errorf("task usage producer kind mismatch") + } + expected, err := BuildEnvelope(expectedProducerKind, contract, envelope.Stage, envelope.Usage) + if err != nil { + return err + } + if envelope.SourceID != expected.SourceID || + envelope.SchemaVersion != expected.SchemaVersion || + envelope.ContractDigest != expected.ContractDigest || + envelope.Presence != expected.Presence || + envelope.Completeness != expected.Completeness { + return fmt.Errorf("task usage envelope identity or digest mismatch") + } + legacyDigestAccepted := legacyEvidenceDigestAllowed(contract) && + envelope.EvidenceDigest == legacyEvidenceDigest(envelope.Stage, envelope.Presence, envelope.Completeness, envelope.Usage) + if envelope.EvidenceDigest != expected.EvidenceDigest && !legacyDigestAccepted { + return fmt.Errorf("task usage envelope identity or digest mismatch") + } + return nil +} + +func validateContract(contract types.TaskUsageContract) error { + if strings.TrimSpace(contract.SourceID) == "" || contract.SourceID != strings.TrimSpace(contract.SourceID) { + return fmt.Errorf("task usage contract source_id is invalid") + } + if contract.SchemaVersion <= 0 { + return fmt.Errorf("task usage contract schema_version is invalid") + } + if len(contract.Fields) == 0 { + return fmt.Errorf("task usage contract fields are required") + } + seen := make(map[string]struct{}, len(contract.Fields)) + for _, field := range contract.Fields { + expectedUnit, ok := canonicalFieldUnits[field.Key] + if !ok || field.Unit != expectedUnit { + return fmt.Errorf("task usage contract field %q has invalid unit", field.Key) + } + if _, duplicate := seen[field.Key]; duplicate { + return fmt.Errorf("task usage contract field %q is duplicated", field.Key) + } + seen[field.Key] = struct{}{} + if field.MinValue != nil && field.MaxValue != nil && *field.MinValue > *field.MaxValue { + return fmt.Errorf("task usage contract field %q has invalid range", field.Key) + } + } + return nil +} + +func validateUsage(contract types.TaskUsageContract, usage *types.TaskUsage, completeness string) (string, error) { + values := usageValues(usage) + allowed := make(map[string]types.TaskUsageFieldContract, len(contract.Fields)) + for _, field := range contract.Fields { + allowed[field.Key] = field + } + for key, value := range values { + if value == nil { + continue + } + field, ok := allowed[key] + if !ok { + return "", fmt.Errorf("task usage field %q is not declared by the contract", key) + } + if completeness == types.TaskUsageCompletenessComplete || completeness == types.TaskUsageCompletenessPartial { + if (field.MinValue != nil && *value < *field.MinValue) || + (field.MaxValue != nil && *value > *field.MaxValue) { + return "", fmt.Errorf("task usage field %q is outside the contract range", key) + } + } + } + if completeness == types.TaskUsageCompletenessComplete || completeness == types.TaskUsageCompletenessPartial { + completionTokens := values[types.TaskUsageFieldCompletionTokens] + totalTokens := values[types.TaskUsageFieldTotalTokens] + if completionTokens != nil && totalTokens != nil && *totalTokens < *completionTokens { + return "", fmt.Errorf("total tokens cannot be less than completion tokens") + } + } + + presentCount := 0 + allZero := true + for _, value := range values { + if value == nil { + continue + } + presentCount++ + if *value != 0 { + allZero = false + } + } + switch completeness { + case types.TaskUsageCompletenessMissing: + if presentCount != 0 { + return "", fmt.Errorf("missing task usage cannot contain canonical facts") + } + return types.TaskUsagePresenceNotPresent, nil + case types.TaskUsageCompletenessPartial: + if presentCount == 0 { + return "", fmt.Errorf("partial task usage requires at least one canonical fact") + } + return types.TaskUsagePresencePartial, nil + case types.TaskUsageCompletenessInvalid: + return types.TaskUsagePresenceInvalid, nil + case types.TaskUsageCompletenessAmbiguous: + return types.TaskUsagePresenceAmbiguous, nil + case types.TaskUsageCompletenessComplete: + for _, field := range contract.Fields { + if field.RequiredForTerminal && values[field.Key] == nil { + return "", fmt.Errorf("complete task usage is missing required field %q", field.Key) + } + } + if presentCount == 0 { + return "", fmt.Errorf("complete task usage requires canonical facts") + } + if allZero { + return types.TaskUsagePresencePresentZero, nil + } + return types.TaskUsagePresencePresentValid, nil + default: + return "", fmt.Errorf("unsupported task usage completeness %q", completeness) + } +} + +func usageValues(usage *types.TaskUsage) map[string]*int64 { + values := map[string]*int64{} + if usage == nil { + return values + } + values[types.TaskUsageFieldOutputDurationMs] = usage.OutputDurationMs + values[types.TaskUsageFieldInputVideoDurationMs] = usage.InputVideoDurationMs + values[types.TaskUsageFieldInputAudioDurationMs] = usage.InputAudioDurationMs + values[types.TaskUsageFieldInputImageCount] = usage.InputImageCount + values[types.TaskUsageFieldInputVideoCount] = usage.InputVideoCount + values[types.TaskUsageFieldInputAudioCount] = usage.InputAudioCount + values[types.TaskUsageFieldCompletionTokens] = usage.CompletionTokens + values[types.TaskUsageFieldTotalTokens] = usage.TotalTokens + return values +} + +func evidenceDigest(contract types.TaskUsageContract, stage, presence, completeness string, usage *types.TaskUsage) string { + values := usageValues(usage) + fields := append([]types.TaskUsageFieldContract(nil), contract.Fields...) + sort.Slice(fields, func(i, j int) bool { return fields[i].Key < fields[j].Key }) + var canonical strings.Builder + fmt.Fprintf(&canonical, "stage=%s\npresence=%s\ncompleteness=%s\n", stage, presence, completeness) + for _, field := range fields { + fmt.Fprintf(&canonical, "%s=%s\n", field.Key, pointerString(values[field.Key])) + } + return sha256Hex(canonical.String()) +} + +// legacyEvidenceDigest accepts envelopes emitted before the digest was scoped +// to the frozen contract. This keeps in-flight task evidence verifiable while +// new envelopes remain stable when later contracts add fields. +func legacyEvidenceDigest(stage, presence, completeness string, usage *types.TaskUsage) string { + values := usageValues(usage) + var canonical strings.Builder + fmt.Fprintf(&canonical, "stage=%s\npresence=%s\ncompleteness=%s\n", stage, presence, completeness) + for _, key := range legacyEvidenceFieldOrder { + fmt.Fprintf(&canonical, "%s=%s\n", key, pointerString(values[key])) + } + return sha256Hex(canonical.String()) +} + +func legacyEvidenceDigestAllowed(contract types.TaskUsageContract) bool { + return contract.SchemaVersion == 1 && + (contract.SourceID == SourceIDMiniMax || contract.SourceID == SourceIDDoubaoVideo) +} + +func pointerString(value *int64) string { + if value == nil { + return "absent" + } + return fmt.Sprintf("%d", *value) +} + +func validStage(stage string) bool { + switch stage { + case types.TaskUsageSourceRequestEstimate, + types.TaskUsageSourceProviderResponse, + types.TaskUsageSourceCallback, + types.TaskUsageSourceManualReconcile: + return true + default: + return false + } +} + +func validCompleteness(completeness string) bool { + switch completeness { + case types.TaskUsageCompletenessComplete, + types.TaskUsageCompletenessPartial, + types.TaskUsageCompletenessMissing, + types.TaskUsageCompletenessInvalid, + types.TaskUsageCompletenessAmbiguous: + return true + default: + return false + } +} + +func sha256Hex(value string) string { + digest := sha256.Sum256([]byte(value)) + return hex.EncodeToString(digest[:]) +} diff --git a/pkg/taskusage/taskusage_test.go b/pkg/taskusage/taskusage_test.go new file mode 100644 index 00000000..428f88d5 --- /dev/null +++ b/pkg/taskusage/taskusage_test.go @@ -0,0 +1,241 @@ +package taskusage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/require" +) + +func TestBuildEnvelopeDistinguishesAbsentAndExplicitZero(t *testing.T) { + contract := DoubaoVideoContract() + + absent, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, nil) + require.NoError(t, err) + require.Equal(t, types.TaskUsagePresenceNotPresent, absent.Presence) + require.Equal(t, types.TaskUsageCompletenessMissing, absent.Completeness) + require.NotEmpty(t, absent.EvidenceDigest) + + zero := int64(0) + explicitZero, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &zero, + TotalTokens: &zero, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + require.Equal(t, types.TaskUsagePresencePresentZero, explicitZero.Presence) + require.NotEqual(t, absent.EvidenceDigest, explicitZero.EvidenceDigest) + require.NotNil(t, explicitZero.Usage.CompletionTokens) + require.Zero(t, *explicitZero.Usage.CompletionTokens) + require.NoError(t, ValidateEnvelope(contract, explicitZero, types.TaskUsageProducerKindGoAdapter)) +} + +func TestBuildEnvelopeRejectsCompletionTokensAboveTotalTokens(t *testing.T) { + contract := DoubaoVideoContract() + completion := int64(4) + total := int64(3) + + for _, completeness := range []string{ + types.TaskUsageCompletenessComplete, + types.TaskUsageCompletenessPartial, + } { + t.Run(completeness, func(t *testing.T) { + _, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &completion, + TotalTokens: &total, + Source: types.TaskUsageSourceProviderResponse, + Completeness: completeness, + }) + require.ErrorContains(t, err, "total tokens cannot be less than completion tokens") + }) + } +} + +func TestEvidenceDigestIsCanonicalAcrossProducerKindsAndFieldOrder(t *testing.T) { + contract := MiniMaxH3Contract() + output := int64(5_000) + video := int64(0) + audio := int64(0) + images := int64(1) + usage := &types.TaskUsage{ + OutputDurationMs: &output, + InputVideoDurationMs: &video, + InputAudioDurationMs: &audio, + InputImageCount: &images, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + goEnvelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, usage) + require.NoError(t, err) + pluginEnvelope, err := BuildEnvelope(types.TaskUsageProducerKindTaskPlugin, contract, types.TaskUsageSourceProviderResponse, usage) + require.NoError(t, err) + require.Equal(t, goEnvelope.EvidenceDigest, pluginEnvelope.EvidenceDigest) + require.Equal(t, goEnvelope.ContractDigest, pluginEnvelope.ContractDigest) + require.NotEqual(t, goEnvelope.ProducerKind, pluginEnvelope.ProducerKind) + + reordered := contract + reordered.Fields = append([]types.TaskUsageFieldContract(nil), contract.Fields...) + for left, right := 0, len(reordered.Fields)-1; left < right; left, right = left+1, right-1 { + reordered.Fields[left], reordered.Fields[right] = reordered.Fields[right], reordered.Fields[left] + } + digest, err := ContractDigest(reordered) + require.NoError(t, err) + require.Equal(t, goEnvelope.ContractDigest, digest) +} + +func TestEvidenceDigestIgnoresCanonicalFieldsOutsideFrozenContract(t *testing.T) { + contract := MiniMaxH3Contract() + output := int64(5_000) + video := int64(0) + audio := int64(0) + images := int64(1) + usage := &types.TaskUsage{ + OutputDurationMs: &output, + InputVideoDurationMs: &video, + InputAudioDurationMs: &audio, + InputImageCount: &images, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + before, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, usage) + require.NoError(t, err) + + // Constructing a later contract with an additional canonical field must not + // change the digest of the already-frozen contract. + later := contract + later.Fields = append(append([]types.TaskUsageFieldContract(nil), contract.Fields...), types.TaskUsageFieldContract{ + Key: types.TaskUsageFieldInputVideoCount, Unit: types.TaskUsageUnitCount, + MinValue: int64Pointer(0), + }) + laterEnvelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, later, types.TaskUsageSourceProviderResponse, usage) + require.NoError(t, err) + + after, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, usage) + require.NoError(t, err) + require.Equal(t, before.EvidenceDigest, after.EvidenceDigest) + require.NotEqual(t, before.EvidenceDigest, laterEnvelope.EvidenceDigest) + + legacy := types.CloneTaskUsageEnvelope(before) + legacy.EvidenceDigest = legacyEvidenceDigest(legacy.Stage, legacy.Presence, legacy.Completeness, legacy.Usage) + require.NoError(t, ValidateEnvelope(contract, legacy, types.TaskUsageProducerKindGoAdapter)) +} + +func TestValidateEnvelopeRejectsLegacyDigestForUnregisteredContracts(t *testing.T) { + base := MiniMaxH3Contract() + tests := []struct { + name string + contract types.TaskUsageContract + }{ + { + name: "unknown source", + contract: func() types.TaskUsageContract { + contract := base + contract.SourceID = "future_provider" + return contract + }(), + }, + { + name: "new schema version", + contract: func() types.TaskUsageContract { + contract := base + contract.SchemaVersion = 2 + return contract + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := int64(5_000) + video := int64(0) + audio := int64(0) + images := int64(1) + envelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, tt.contract, types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + OutputDurationMs: &output, + InputVideoDurationMs: &video, + InputAudioDurationMs: &audio, + InputImageCount: &images, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + envelope.EvidenceDigest = legacyEvidenceDigest(envelope.Stage, envelope.Presence, envelope.Completeness, envelope.Usage) + require.ErrorContains(t, ValidateEnvelope(tt.contract, envelope, types.TaskUsageProducerKindGoAdapter), "digest") + }) + } +} + +func TestValidateEnvelopeRejectsIdentityAndDigestTampering(t *testing.T) { + zero := int64(0) + envelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, DoubaoVideoContract(), types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &zero, + TotalTokens: &zero, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + + tampered := types.CloneTaskUsageEnvelope(envelope) + tampered.SourceID = "client-supplied-source" + require.Error(t, ValidateEnvelope(DoubaoVideoContract(), tampered, types.TaskUsageProducerKindGoAdapter)) + + tampered = types.CloneTaskUsageEnvelope(envelope) + *tampered.Usage.TotalTokens = 1 + require.Error(t, ValidateEnvelope(DoubaoVideoContract(), tampered, types.TaskUsageProducerKindGoAdapter)) +} + +func TestValidateEnvelopeRejectsProducerIdentityTampering(t *testing.T) { + zero := int64(0) + envelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, DoubaoVideoContract(), types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &zero, + TotalTokens: &zero, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + envelope.ProducerKind = types.TaskUsageProducerKindTaskPlugin + require.Error(t, ValidateEnvelope(DoubaoVideoContract(), envelope, types.TaskUsageProducerKindGoAdapter)) +} + +func TestResolveContractUsesFrozenIdentity(t *testing.T) { + contract := MiniMaxH3Contract() + digest, err := ContractDigest(contract) + require.NoError(t, err) + + resolved, err := ResolveContract(contract.SourceID, contract.SchemaVersion, digest) + require.NoError(t, err) + require.Equal(t, contract, resolved) + + _, err = ResolveContract(contract.SourceID, contract.SchemaVersion, "tampered") + require.ErrorContains(t, err, "digest") + _, err = ResolveContract("unknown-source", 1, digest) + require.ErrorContains(t, err, "unsupported") +} + +func TestTaskPluginFixtureMatchesGoAdapterCanonicalFacts(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "task-plugin-minimax-h3-v1.json")) + require.NoError(t, err) + var fixture struct { + ProducerKind string `json:"producer_kind"` + SourceID string `json:"source_id"` + SchemaVersion int `json:"schema_version"` + Stage string `json:"stage"` + Usage *types.TaskUsage `json:"usage"` + } + require.NoError(t, common.Unmarshal(data, &fixture)) + contract := MiniMaxH3Contract() + require.Equal(t, contract.SourceID, fixture.SourceID) + require.Equal(t, contract.SchemaVersion, fixture.SchemaVersion) + + pluginEnvelope, err := BuildEnvelope(fixture.ProducerKind, contract, fixture.Stage, fixture.Usage) + require.NoError(t, err) + goEnvelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, fixture.Stage, fixture.Usage) + require.NoError(t, err) + require.Equal(t, goEnvelope.EvidenceDigest, pluginEnvelope.EvidenceDigest) + require.NoError(t, ValidateEnvelope(contract, pluginEnvelope, types.TaskUsageProducerKindTaskPlugin)) +} diff --git a/pkg/taskusage/testdata/task-plugin-minimax-h3-v1.json b/pkg/taskusage/testdata/task-plugin-minimax-h3-v1.json new file mode 100644 index 00000000..addca331 --- /dev/null +++ b/pkg/taskusage/testdata/task-plugin-minimax-h3-v1.json @@ -0,0 +1,14 @@ +{ + "producer_kind": "task_plugin", + "source_id": "minimax", + "schema_version": 1, + "stage": "provider_response", + "usage": { + "output_duration_ms": 5000, + "input_video_duration_ms": 0, + "input_audio_duration_ms": 0, + "input_image_count": 1, + "source": "provider_response", + "completeness": "complete" + } +} diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 9345b986..da74efaa 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -89,6 +89,18 @@ type TaskUsageProvider interface { ExtractTaskUsage(respBody []byte) (*types.TaskUsage, error) } +// TaskUsageFactProvider is the versioned, auditable usage-fact seam. It is a +// pure producer contract: implementations may parse and normalize Payload but +// must not perform network I/O, pricing, task/log writes, or funding changes. +// TaskUsageProvider remains as the compatibility entry for existing adaptors. +type TaskUsageFactProvider interface { + UsageContract() types.TaskUsageContract + // UsageProducerKind is host-owned provenance. The polling boundary must + // validate against this trusted value instead of the envelope's self-report. + UsageProducerKind() string + ProduceUsage(ctx types.TaskUsageContext) (*types.TaskUsageEnvelope, error) +} + // TaskBillingPlanProvider is an optional provider-specific request normalizer. // It only produces a bounded pricing snapshot; it has no funding authority. type TaskBillingPlanProvider interface { diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 836ac850..2e176a18 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -2,6 +2,7 @@ package doubao import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -14,10 +15,12 @@ import ( "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" "github.com/MAX-API-Next/MAX-API/relay/channel" "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/types" "github.com/gin-gonic/gin" "github.com/pkg/errors" @@ -85,13 +88,7 @@ type responseTask struct { Tools []struct { Type string `json:"type"` } `json:"tools"` - Usage struct { - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - ToolUsage struct { - WebSearch int `json:"web_search"` - } `json:"tool_usage"` - } `json:"usage"` + Usage json.RawMessage `json:"usage"` Error struct { Code string `json:"code"` Message string `json:"message"` @@ -100,6 +97,25 @@ type responseTask struct { UpdatedAt int64 `json:"updated_at"` } +// responseTaskResult contains only the fields needed by the polling state +// machine. Provider gateways sometimes add fields with incompatible JSON +// types; keeping those fields out of this decode lets status and media URL +// parsing remain independent from unrelated metadata. +type responseTaskResult struct { + Status string `json:"status"` + Content struct { + VideoURL string `json:"video_url"` + } `json:"content"` + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +type responseTaskUsage struct { + CompletionTokens *int `json:"completion_tokens"` + TotalTokens *int `json:"total_tokens"` +} + // ============================ // Adaptor implementation // ============================ @@ -111,6 +127,8 @@ type TaskAdaptor struct { baseURL string } +var _ channel.TaskUsageFactProvider = (*TaskAdaptor)(nil) + func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl @@ -565,14 +583,24 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e }); err != nil { return nil, err } else if ok { + if err := a.attachUsageEnvelope(respBody, taskResult); err != nil { + return nil, err + } return taskResult, nil } } - resTask := responseTask{} - if err := common.Unmarshal(respBody, &resTask); err != nil { + payload := unwrapProviderPayload(respBody) + resTask := responseTaskResult{} + if err := common.Unmarshal(payload, &resTask); err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") } + envelope, err := a.ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, Payload: payload, + }) + if err != nil { + return nil, err + } taskResult := relaycommon.TaskInfo{ Code: 0, @@ -591,8 +619,16 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e taskResult.Progress = "100%" taskResult.Url = resTask.Content.VideoURL // 解析 usage 信息用于按倍率计费 - taskResult.CompletionTokens = resTask.Usage.CompletionTokens - taskResult.TotalTokens = resTask.Usage.TotalTokens + // Invalid structured facts must not leak into the legacy billing fields: + // the legacy path may use TotalTokens for a quota recalculation. + if envelope.Completeness == types.TaskUsageCompletenessComplete && envelope.Usage != nil { + if envelope.Usage.CompletionTokens != nil { + taskResult.CompletionTokens = int(*envelope.Usage.CompletionTokens) + } + if envelope.Usage.TotalTokens != nil { + taskResult.TotalTokens = int(*envelope.Usage.TotalTokens) + } + } case "failed": taskResult.Status = model.TaskStatusFailure taskResult.Progress = "100%" @@ -602,10 +638,120 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e taskResult.Status = model.TaskStatusInProgress taskResult.Progress = "30%" } + taskResult.UsageEnvelope = envelope + if envelope != nil { + taskResult.Usage = types.CloneTaskUsage(envelope.Usage) + } return &taskResult, nil } +func (a *TaskAdaptor) UsageContract() types.TaskUsageContract { + return taskusage.DoubaoVideoContract() +} + +func (a *TaskAdaptor) UsageProducerKind() string { + return types.TaskUsageProducerKindGoAdapter +} + +func (a *TaskAdaptor) ProduceUsage(ctx types.TaskUsageContext) (*types.TaskUsageEnvelope, error) { + if ctx.Stage != types.TaskUsageSourceProviderResponse { + return nil, fmt.Errorf("Doubao task usage stage %q is not supported", ctx.Stage) + } + payload := unwrapProviderPayload(ctx.Payload) + // Only decode the usage member here. Configured providers may include + // otherwise valid fields with types that are incompatible with the native + // Doubao responseTask shape (for example string timestamps or durations). + var response struct { + Usage json.RawMessage `json:"usage"` + } + if err := common.Unmarshal(payload, &response); err != nil { + return nil, errors.Wrap(err, "unmarshal Doubao task usage failed") + } + return buildDoubaoUsageEnvelope(response.Usage) +} + +func (a *TaskAdaptor) attachUsageEnvelope(respBody []byte, taskResult *relaycommon.TaskInfo) error { + envelope, err := a.ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, Payload: respBody, + }) + if err != nil { + return err + } + taskResult.UsageEnvelope = envelope + if envelope != nil { + taskResult.Usage = types.CloneTaskUsage(envelope.Usage) + } + return nil +} + +func unwrapProviderPayload(respBody []byte) []byte { + payload := respBody + for depth := 0; depth < taskcommon.MaxWrappedTaskUnwrapDepth; depth++ { + nested := taskcommon.WrappedTaskProviderPayload(payload) + if nested == nil { + break + } + payload = nested + } + return payload +} + +func buildDoubaoUsageEnvelope(raw json.RawMessage) (*types.TaskUsageEnvelope, error) { + var parsed responseTaskUsage + completeness := types.TaskUsageCompletenessMissing + switch common.GetJsonType(raw) { + case "unknown": + return taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.DoubaoVideoContract(), + types.TaskUsageSourceProviderResponse, + nil, + ) + case "null": + completeness = types.TaskUsageCompletenessInvalid + case "object": + if err := common.Unmarshal(raw, &parsed); err != nil { + completeness = types.TaskUsageCompletenessInvalid + } + default: + completeness = types.TaskUsageCompletenessInvalid + } + usage := &types.TaskUsage{ + Source: types.TaskUsageSourceProviderResponse, + Completeness: completeness, + } + if parsed.CompletionTokens != nil { + value := int64(*parsed.CompletionTokens) + usage.CompletionTokens = &value + } + if parsed.TotalTokens != nil { + value := int64(*parsed.TotalTokens) + usage.TotalTokens = &value + } + if completeness != types.TaskUsageCompletenessInvalid { + switch { + case parsed.CompletionTokens == nil && parsed.TotalTokens == nil: + usage.Completeness = types.TaskUsageCompletenessInvalid + case (parsed.CompletionTokens != nil && *parsed.CompletionTokens < 0) || + (parsed.TotalTokens != nil && *parsed.TotalTokens < 0): + usage.Completeness = types.TaskUsageCompletenessInvalid + case parsed.CompletionTokens == nil || parsed.TotalTokens == nil: + usage.Completeness = types.TaskUsageCompletenessPartial + case *parsed.TotalTokens < *parsed.CompletionTokens: + usage.Completeness = types.TaskUsageCompletenessInvalid + default: + usage.Completeness = types.TaskUsageCompletenessComplete + } + } + return taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.DoubaoVideoContract(), + types.TaskUsageSourceProviderResponse, + usage, + ) +} + func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { if body, ok, err := taskcommon.ConvertConfiguredTaskToOpenAIVideo(originTask); ok || err != nil { return body, err @@ -649,7 +795,14 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro openAIVideo.TaskID = originTask.TaskID openAIVideo.Status = originTask.Status.ToVideoStatus() openAIVideo.SetProgressStr(originTask.Progress) - openAIVideo.SetMetadata("url", dResp.Content.VideoURL) + resultURL := dResp.Content.VideoURL + if resultURL == "" { + // Configured or relay-wrapped polling responses may persist the + // provider URL in the task snapshot while the raw response has no + // top-level Doubao content field. + resultURL = originTask.GetResultURL() + } + openAIVideo.SetMetadata("url", resultURL) openAIVideo.CreatedAt = originTask.CreatedAt openAIVideo.CompletedAt = originTask.UpdatedAt openAIVideo.Model = originTask.Properties.OriginModelName diff --git a/relay/channel/task/doubao/adaptor_billing_test.go b/relay/channel/task/doubao/adaptor_billing_test.go index 534f8ceb..1ca1da5d 100644 --- a/relay/channel/task/doubao/adaptor_billing_test.go +++ b/relay/channel/task/doubao/adaptor_billing_test.go @@ -542,3 +542,20 @@ func TestConvertSeedanceMediaTaskToOpenAIVideoByShape(t *testing.T) { assert.Contains(t, string(body), `"url":"https://example.com/result.mp4"`) assert.Contains(t, string(body), `"task_id":"task_123"`) } + +func TestConvertDoubaoWrappedTaskUsesPersistedResultURL(t *testing.T) { + body, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(&model.Task{ + TaskID: "task_wrapped_123", + Status: model.TaskStatusSuccess, + Progress: "100%", + Data: []byte(`{"code":"success","data":{"data":{"status":"succeeded"}}}`), + PrivateData: model.TaskPrivateData{ + ResultURL: "https://cdn.example.com/wrapped-result.mp4", + }, + CreatedAt: 1710000000, + UpdatedAt: 1710000100, + }) + + require.NoError(t, err) + assert.Contains(t, string(body), `"url":"https://cdn.example.com/wrapped-result.mp4"`) +} diff --git a/relay/channel/task/doubao/adaptor_usage_test.go b/relay/channel/task/doubao/adaptor_usage_test.go new file mode 100644 index 00000000..099efcd2 --- /dev/null +++ b/relay/channel/task/doubao/adaptor_usage_test.go @@ -0,0 +1,196 @@ +package doubao + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" + "github.com/MAX-API-Next/MAX-API/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProduceUsagePreservesDoubaoExplicitZero(t *testing.T) { + envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, + Payload: []byte(`{"status":"succeeded","usage":{"completion_tokens":0,"total_tokens":0}}`), + }) + require.NoError(t, err) + require.Equal(t, types.TaskUsageProducerKindGoAdapter, envelope.ProducerKind) + require.Equal(t, taskusage.DoubaoVideoContract().SourceID, envelope.SourceID) + require.Equal(t, types.TaskUsagePresencePresentZero, envelope.Presence) + require.NotNil(t, envelope.Usage.CompletionTokens) + require.NotNil(t, envelope.Usage.TotalTokens) + require.Zero(t, *envelope.Usage.CompletionTokens) + require.Zero(t, *envelope.Usage.TotalTokens) +} + +func TestProduceUsageClassifiesDoubaoMissingPartialAndInvalid(t *testing.T) { + tests := []struct { + name string + body string + presence string + completeness string + usageNonNil bool + }{ + {name: "missing", body: `{"status":"succeeded"}`, presence: types.TaskUsagePresenceNotPresent, completeness: types.TaskUsageCompletenessMissing}, + {name: "partial", body: `{"status":"succeeded","usage":{"completion_tokens":3}}`, presence: types.TaskUsagePresencePartial, completeness: types.TaskUsageCompletenessPartial, usageNonNil: true}, + {name: "empty object", body: `{"status":"succeeded","usage":{}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "null token fields", body: `{"status":"succeeded","usage":{"completion_tokens":null,"total_tokens":null}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "unrecognized fields", body: `{"status":"succeeded","usage":{"prompt_tokens":12}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "single negative completion", body: `{"status":"succeeded","usage":{"completion_tokens":-1}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "single negative total", body: `{"status":"succeeded","usage":{"total_tokens":-1}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "negative", body: `{"status":"succeeded","usage":{"completion_tokens":-1,"total_tokens":2}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "inconsistent", body: `{"status":"succeeded","usage":{"completion_tokens":4,"total_tokens":3}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "non numeric token", body: `{"status":"succeeded","usage":{"completion_tokens":"many"}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + {name: "null usage", body: `{"status":"succeeded","usage":null}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid, usageNonNil: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, Payload: []byte(test.body), + }) + require.NoError(t, err) + require.Equal(t, test.presence, envelope.Presence) + require.Equal(t, test.completeness, envelope.Completeness) + if test.usageNonNil { + assert.NotNil(t, envelope.Usage) + } else { + assert.Nil(t, envelope.Usage) + } + }) + } +} + +func TestProduceUsageConfiguredResponseIgnoresIncompatibleUnrelatedFields(t *testing.T) { + envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, + Payload: []byte(`{ + "code":"success", + "data":{"data":{ + "id":"task_wrapped", + "status":"succeeded", + "duration":"5", + "created_at":"not-a-timestamp", + "updated_at":false, + "usage":{"completion_tokens":12,"total_tokens":20} + }} + }`), + }) + require.NoError(t, err) + require.Equal(t, types.TaskUsagePresencePresentValid, envelope.Presence) + require.EqualValues(t, 12, *envelope.Usage.CompletionTokens) + require.EqualValues(t, 20, *envelope.Usage.TotalTokens) +} + +func TestParseTaskResultAttachesDoubaoUsageEnvelopeWithoutChangingLegacyTokens(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "status":"succeeded", + "content":{"video_url":"https://cdn.example.com/video.mp4"}, + "usage":{"completion_tokens":12,"total_tokens":20} + }`)) + require.NoError(t, err) + require.Equal(t, 12, result.CompletionTokens) + require.Equal(t, 20, result.TotalTokens) + require.NotNil(t, result.UsageEnvelope) + require.Equal(t, types.TaskUsagePresencePresentValid, result.UsageEnvelope.Presence) + require.NotNil(t, result.UsageEnvelope.Usage) + require.NotNil(t, result.UsageEnvelope.Usage.CompletionTokens) + require.EqualValues(t, result.CompletionTokens, *result.UsageEnvelope.Usage.CompletionTokens) + require.NotNil(t, result.UsageEnvelope.Usage.TotalTokens) + require.EqualValues(t, result.TotalTokens, *result.UsageEnvelope.Usage.TotalTokens) +} + +func TestParseTaskResultDoesNotLetNonObjectUsageHideTerminalResult(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "status":"succeeded", + "content":{"video_url":"https://cdn.example.com/video.mp4"}, + "usage":[] + }`)) + require.NoError(t, err) + require.Equal(t, "SUCCESS", result.Status) + require.Equal(t, "https://cdn.example.com/video.mp4", result.Url) + require.NotNil(t, result.UsageEnvelope) + require.Equal(t, types.TaskUsagePresenceInvalid, result.UsageEnvelope.Presence) + require.Equal(t, types.TaskUsageCompletenessInvalid, result.UsageEnvelope.Completeness) +} + +func TestParseTaskResultDoesNotCopyInvalidUsageIntoLegacyBillingFields(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "status":"succeeded", + "content":{"video_url":"https://cdn.example.com/video.mp4"}, + "usage":{"completion_tokens":100,"total_tokens":1} + }`)) + require.NoError(t, err) + require.Equal(t, "SUCCESS", result.Status) + require.Equal(t, types.TaskUsageCompletenessInvalid, result.UsageEnvelope.Completeness) + // Invalid totals must not reach the compatibility fields used by legacy + // token-ratio settlement. + require.Zero(t, result.CompletionTokens) + require.Zero(t, result.TotalTokens) +} + +func TestParseTaskResultDoesNotCopyPartialUsageIntoLegacyBillingFields(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "status":"succeeded", + "content":{"video_url":"https://cdn.example.com/video.mp4"}, + "usage":{"total_tokens":20} + }`)) + require.NoError(t, err) + require.Equal(t, types.TaskUsageCompletenessPartial, result.UsageEnvelope.Completeness) + assert.Zero(t, result.CompletionTokens) + assert.Zero(t, result.TotalTokens) +} + +func TestProduceUsageUnwrapsConfiguredRelayEnvelope(t *testing.T) { + envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, + Payload: []byte(`{ + "code":"success", + "data":{"data":{ + "id":"task_123", + "status":"succeeded", + "usage":{"completion_tokens":12,"total_tokens":20} + }} + }`), + }) + require.NoError(t, err) + require.Equal(t, types.TaskUsagePresencePresentValid, envelope.Presence) + require.EqualValues(t, 12, *envelope.Usage.CompletionTokens) + require.EqualValues(t, 20, *envelope.Usage.TotalTokens) +} + +func TestParseTaskResultUsesTheSameWrappedUsageAsProduceUsage(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "code":"success", + "data":{"data":{ + "id":"task_wrapped", + "status":"succeeded", + "content":{"video_url":"https://cdn.example.com/wrapped.mp4"}, + "usage":{"completion_tokens":12,"total_tokens":20} + }} + }`)) + require.NoError(t, err) + require.NotNil(t, result.UsageEnvelope) + require.Equal(t, types.TaskUsagePresencePresentValid, result.UsageEnvelope.Presence) + require.EqualValues(t, 12, *result.UsageEnvelope.Usage.CompletionTokens) + require.EqualValues(t, 20, *result.UsageEnvelope.Usage.TotalTokens) + require.Equal(t, "SUCCESS", result.Status) + require.Equal(t, "https://cdn.example.com/wrapped.mp4", result.Url) +} + +func TestParseTaskResultUnwrapsNestedProviderPayloadForStatusAndURL(t *testing.T) { + result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "code":"success", + "data":{"data":{ + "id":"task_wrapped", + "status":"succeeded", + "content":{"video_url":"https://cdn.example.com/nested.mp4"}, + "usage":{"completion_tokens":12,"total_tokens":20} + }} + }`)) + require.NoError(t, err) + require.Equal(t, "SUCCESS", result.Status) + require.Equal(t, "https://cdn.example.com/nested.mp4", result.Url) + require.EqualValues(t, 12, result.CompletionTokens) + require.EqualValues(t, 20, result.TotalTokens) +} diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index 9999f614..df1feba1 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -18,6 +18,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/pkg/taskusage" "github.com/MAX-API-Next/MAX-API/relay/channel" taskcommon "github.com/MAX-API-Next/MAX-API/relay/channel/task/taskcommon" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -35,6 +36,7 @@ type TaskAdaptor struct { } var _ channel.TaskUsageProvider = (*TaskAdaptor)(nil) +var _ channel.TaskUsageFactProvider = (*TaskAdaptor)(nil) func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { a.ChannelType = info.ChannelType @@ -308,10 +310,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 + return attachH3UsageEnvelope(result, err) } if result, handled, err := parseGenericMiniMaxTaskResult(respBody); handled { - return result, err + return attachH3UsageEnvelope(result, err) } resTask := QueryTaskResponse{} @@ -367,6 +369,92 @@ func (a *TaskAdaptor) ExtractTaskUsage(respBody []byte) (*types.TaskUsage, error return a.extractTaskUsage(respBody, 0) } +func (a *TaskAdaptor) UsageContract() types.TaskUsageContract { + return taskusage.MiniMaxH3Contract() +} + +func (a *TaskAdaptor) UsageProducerKind() string { + return types.TaskUsageProducerKindGoAdapter +} + +func (a *TaskAdaptor) ProduceUsage(ctx types.TaskUsageContext) (*types.TaskUsageEnvelope, error) { + if ctx.Stage != types.TaskUsageSourceProviderResponse { + return nil, fmt.Errorf("H3 task usage stage %q is not supported", ctx.Stage) + } + usage, err := a.ExtractTaskUsage(ctx.Payload) + if err != nil { + return nil, err + } + envelope, buildErr := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + a.UsageContract(), + ctx.Stage, + usage, + ) + if buildErr == nil { + return envelope, nil + } + // Preserve the provider response as an auditable invalid fact so polling + // can reach a terminal state while billing remains reconciliation-gated. + invalidUsage := types.CloneTaskUsage(usage) + if invalidUsage == nil { + invalidUsage = &types.TaskUsage{} + } + invalidUsage.Source = ctx.Stage + invalidUsage.Completeness = types.TaskUsageCompletenessInvalid + invalidEnvelope, fallbackErr := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + a.UsageContract(), + ctx.Stage, + invalidUsage, + ) + if fallbackErr != nil { + return nil, buildErr + } + return invalidEnvelope, nil +} + +func attachH3UsageEnvelope(result *relaycommon.TaskInfo, parseErr error) (*relaycommon.TaskInfo, error) { + if parseErr != nil || result == nil { + return result, parseErr + } + envelope, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceProviderResponse, + result.Usage, + ) + if err != nil { + // Keep the parsed task state available to polling even when the + // provider usage cannot be bound to the host contract. Mark the + // evidence invalid so the task remains reconciliation-gated instead + // of allowing an unverified usage value to settle billing. + invalidUsage := types.CloneTaskUsage(result.Usage) + if invalidUsage == nil { + invalidUsage = &types.TaskUsage{} + } + invalidUsage.Source = types.TaskUsageSourceProviderResponse + invalidUsage.Completeness = types.TaskUsageCompletenessInvalid + invalidEnvelope, fallbackErr := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceProviderResponse, + invalidUsage, + ) + if fallbackErr == nil { + result.Usage = invalidUsage + result.UsageEnvelope = invalidEnvelope + return result, nil + } + // The fallback contract is host-owned and should always be valid. If + // that invariant is ever broken, retain the result and surface the + // original binding error for the caller's retry/reconciliation path. + return result, err + } + result.UsageEnvelope = envelope + return result, nil +} + func (a *TaskAdaptor) extractTaskUsage(respBody []byte, unwrapDepth int) (*types.TaskUsage, error) { if unwrapDepth < taskcommon.MaxWrappedTaskUnwrapDepth { if nested := taskcommon.WrappedTaskProviderPayload(respBody); nested != nil { diff --git a/relay/channel/task/hailuo/adaptor_test.go b/relay/channel/task/hailuo/adaptor_test.go index fe03c2bf..225723b7 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -507,6 +507,53 @@ func TestExtractTaskUsageReadsGenericMiniMaxVideoResponse(t *testing.T) { assert.Equal(t, int64(1), *usage.InputImageCount) } +func TestProduceUsageReturnsAuditableH3Envelope(t *testing.T) { + envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, + Payload: []byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"completed", + "usage":{"input_seconds":0,"input_audio_seconds":0,"output_seconds":5,"input_image_count":1,"total_seconds":5} + }`), + }) + require.NoError(t, err) + require.Equal(t, types.TaskUsageProducerKindGoAdapter, envelope.ProducerKind) + require.Equal(t, "minimax", envelope.SourceID) + require.Equal(t, 1, envelope.SchemaVersion) + require.Equal(t, types.TaskUsagePresencePresentValid, envelope.Presence) + require.NotEmpty(t, envelope.ContractDigest) + require.NotEmpty(t, envelope.EvidenceDigest) + require.NotNil(t, envelope.Usage.InputVideoDurationMs) + require.Zero(t, *envelope.Usage.InputVideoDurationMs) + require.NotNil(t, envelope.Usage.InputAudioDurationMs) + require.Zero(t, *envelope.Usage.InputAudioDurationMs) + require.NotNil(t, envelope.Usage.OutputDurationMs) + require.EqualValues(t, 5_000, *envelope.Usage.OutputDurationMs) + require.NotNil(t, envelope.Usage.InputImageCount) + require.EqualValues(t, 1, *envelope.Usage.InputImageCount) +} + +func TestProduceUsageKeepsOutOfRangeFactsAsInvalidEnvelope(t *testing.T) { + envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, + Payload: []byte(`{ + "id":"439499419230570", + "object":"video.generation", + "status":"completed", + "usage":{"input_seconds":0,"output_seconds":16,"input_image_count":0,"total_seconds":16} + }`), + }) + + require.NoError(t, err) + require.NotNil(t, envelope) + assert.Equal(t, types.TaskUsageCompletenessInvalid, envelope.Completeness) + assert.Equal(t, types.TaskUsagePresenceInvalid, envelope.Presence) + require.NotNil(t, envelope.Usage) + require.NotNil(t, envelope.Usage.OutputDurationMs) + assert.EqualValues(t, 16_000, *envelope.Usage.OutputDurationMs) +} + func TestExtractTaskUsageIgnoresNonObjectGenericUsage(t *testing.T) { for _, value := range []string{`null`, `[]`, `"none"`, `123`} { t.Run(value, func(t *testing.T) { @@ -794,6 +841,36 @@ func TestParseH3UsageMarksInvalidProviderValues(t *testing.T) { require.Nil(t, result.Usage.InputImageCount) } +func TestAttachH3UsageEnvelopePreservesParsedResultOnBindingError(t *testing.T) { + result := &relaycommon.TaskInfo{ + TaskID: "task-envelope-binding-error", + Status: model.TaskStatusSuccess, + Progress: "100%", + Url: "https://cdn.example.com/video.mp4", + Usage: &types.TaskUsage{ + Source: types.TaskUsageSourceCallback, + Completeness: types.TaskUsageCompletenessComplete, + OutputDurationMs: ptrInt64ForTest(16_000), + }, + } + + parsed, err := attachH3UsageEnvelope(result, nil) + + require.NoError(t, err) + require.Same(t, result, parsed) + require.Equal(t, model.TaskStatusSuccess, parsed.Status) + require.Equal(t, "https://cdn.example.com/video.mp4", parsed.Url) + require.NotNil(t, parsed.UsageEnvelope) + require.Equal(t, types.TaskUsageCompletenessInvalid, parsed.UsageEnvelope.Completeness) + require.NotNil(t, parsed.Usage) + require.NotNil(t, parsed.Usage.OutputDurationMs) + assert.EqualValues(t, 16_000, *parsed.Usage.OutputDurationMs) +} + +func ptrInt64ForTest(value int64) *int64 { + return &value +} + func TestParseH3UsagePreservesFractionalVideoAndAudioSeconds(t *testing.T) { result, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ "task": { diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index 11561b15..99770da6 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -560,7 +560,8 @@ func TestParseConfiguredTaskResultSupportsOfficialMiniMaxTaskEnvelope(t *testing "id": "424010985738629", "model": "MiniMax-H3", "status": "succeeded", - "content": {"url": "https://cdn.example.com/h3.mp4"} + "content": {"url": "https://cdn.example.com/h3.mp4"}, + "usage": {"prompt_tokens": 1234} } }`) @@ -572,6 +573,66 @@ func TestParseConfiguredTaskResultSupportsOfficialMiniMaxTaskEnvelope(t *testing assert.Equal(t, "424010985738629", result.TaskID) assert.Equal(t, string(model.TaskStatusSuccess), result.Status) assert.Equal(t, "https://cdn.example.com/h3.mp4", result.Url) + assert.Equal(t, 1234, result.PromptTokens) +} + +func TestParseConfiguredTaskResultReadsAllOfficialTaskUsageTokens(t *testing.T) { + settings := dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo} + result, parsed, err := ParseConfiguredTaskResult([]byte(`{ + "task": { + "id": "424010985738629", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/h3.mp4"}, + "usage": {"prompt_tokens": 1234, "completion_tokens": 567, "total_tokens": 1801} + } + }`), settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, 1234, result.PromptTokens) + assert.Equal(t, 567, result.CompletionTokens) + assert.Equal(t, 1801, result.TotalTokens) +} + +func TestParseConfiguredTaskResultPreservesExplicitOfficialTokenZeros(t *testing.T) { + settings := dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo} + result, parsed, err := ParseConfiguredTaskResult([]byte(`{ + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "task": { + "id": "424010985738629", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/h3.mp4"}, + "usage": {"prompt_tokens": 1234, "completion_tokens": 567, "total_tokens": 1801} + } + }`), settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Zero(t, result.PromptTokens) + assert.Zero(t, result.CompletionTokens) + assert.Zero(t, result.TotalTokens) +} + +func TestParseConfiguredTaskResultPreservesOfficialPromptTokenZero(t *testing.T) { + settings := dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo} + body := []byte(`{ + "task": { + "id": "424010985738629", + "model": "MiniMax-H3", + "status": "succeeded", + "content": {"url": "https://cdn.example.com/h3.mp4"}, + "usage": {"prompt_tokens": 0} + } + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, 0, result.PromptTokens) } func TestParseConfiguredTaskResultKeepsOfficialMiniMaxTaskPendingWithoutURL(t *testing.T) { diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index c644db18..45099735 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -286,8 +286,21 @@ func parseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting status = string(model.TaskStatusSuccess) } progress := NormalizeConfiguredProgress(progressRaw, status) - completionTokens := int(gjson.GetBytes(respBody, "usage.completion_tokens").Int()) - totalTokens := int(gjson.GetBytes(respBody, "usage.total_tokens").Int()) + completionTokenResult := gjson.GetBytes(respBody, "usage.completion_tokens") + if !completionTokenResult.Exists() && officialTaskEnvelope { + completionTokenResult = gjson.GetBytes(respBody, "task.usage.completion_tokens") + } + completionTokens := int(completionTokenResult.Int()) + totalTokenResult := gjson.GetBytes(respBody, "usage.total_tokens") + if !totalTokenResult.Exists() && officialTaskEnvelope { + totalTokenResult = gjson.GetBytes(respBody, "task.usage.total_tokens") + } + totalTokens := int(totalTokenResult.Int()) + promptTokenResult := gjson.GetBytes(respBody, "usage.prompt_tokens") + if !promptTokenResult.Exists() && officialTaskEnvelope { + promptTokenResult = gjson.GetBytes(respBody, "task.usage.prompt_tokens") + } + promptTokens := int(promptTokenResult.Int()) return &relaycommon.TaskInfo{ Code: 0, @@ -296,6 +309,7 @@ func parseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting Progress: progress, Url: resultURL, Reason: reason, + PromptTokens: promptTokens, CompletionTokens: completionTokens, TotalTokens: totalTokens, }, true, nil diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 74c3b174..a3a4dd89 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -925,16 +925,18 @@ 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"` // 用于按倍率计费 - Usage *types.TaskUsage `json:"usage,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"` + PromptTokens int `json:"prompt_tokens,omitempty"` // 用于按倍率计费兼容字段 + CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 + TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 + Usage *types.TaskUsage `json:"usage,omitempty"` + UsageEnvelope *types.TaskUsageEnvelope `json:"usage_envelope,omitempty"` } func FailTaskInfo(reason string) *TaskInfo { diff --git a/router/api-router.go b/router/api-router.go index 9800e1e8..5f1c6bad 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -249,6 +249,8 @@ func SetApiRouter(router *gin.Engine) { smartOpsRoute.POST("/billing-settlements/reviews", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.ReviewBillingSettlements) smartOpsRoute.POST("/billing-settlements/:id/review", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.ReviewBillingSettlement) smartOpsRoute.POST("/billing-settlements/:id/complete-task", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.CompleteManualTaskBillingSettlement) + smartOpsRoute.POST("/billing-settlements/complete-tasks", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.CompleteManualTaskBillingSettlements) + smartOpsRoute.POST("/billing-settlements/complete-tasks-zero", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.CompleteManualTaskBillingSettlementsZero) smartOpsRoute.GET("/channel-performance", controller.GetChannelPerformance) smartOpsRoute.GET("/channel-performance/detail", controller.GetChannelPerformanceDetail) smartOpsRoute.GET("/model-performance", controller.GetModelPerformance) diff --git a/router/api_router_test.go b/router/api_router_test.go index 24dcdc14..c8f7bef8 100644 --- a/router/api_router_test.go +++ b/router/api_router_test.go @@ -308,6 +308,50 @@ func TestBillingSettlementBlockingPolicyRequiresRootRole(t *testing.T) { rootCompletion := completeTask(login(root.Id)) require.Equal(t, http.StatusConflict, rootCompletion.Code) require.Contains(t, rootCompletion.Body.String(), `"success":false`) + + completeTasksZero := func(cookies []*http.Cookie) *httptest.ResponseRecorder { + request := httptest.NewRequest( + http.MethodPost, + "/api/smart-ops/billing-settlements/complete-tasks-zero", + strings.NewReader(`{"items":[]}`), + ) + request.Header.Set("Content-Type", "application/json") + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + return recorder + } + + adminZero := completeTasksZero(login(admin.Id)) + require.Equal(t, http.StatusOK, adminZero.Code) + require.Contains(t, adminZero.Body.String(), `"success":false`) + rootZero := completeTasksZero(login(root.Id)) + require.Equal(t, http.StatusBadRequest, rootZero.Code) + require.Contains(t, rootZero.Body.String(), `"success":false`) + + completeTasksExact := func(cookies []*http.Cookie) *httptest.ResponseRecorder { + request := httptest.NewRequest( + http.MethodPost, + "/api/smart-ops/billing-settlements/complete-tasks", + strings.NewReader(`{"items":[]}`), + ) + request.Header.Set("Content-Type", "application/json") + for _, sessionCookie := range cookies { + request.AddCookie(sessionCookie) + } + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + return recorder + } + + adminExact := completeTasksExact(login(admin.Id)) + require.Equal(t, http.StatusOK, adminExact.Code) + require.Contains(t, adminExact.Body.String(), `"success":false`) + rootExact := completeTasksExact(login(root.Id)) + require.Equal(t, http.StatusBadRequest, rootExact.Code) + require.Contains(t, rootExact.Body.String(), `"success":false`) } func TestUniversalVerifyRateLimitFollowsUserAcrossIPs(t *testing.T) { diff --git a/service/manual_task_settlement_test.go b/service/manual_task_settlement_test.go index c8d7397e..732574ab 100644 --- a/service/manual_task_settlement_test.go +++ b/service/manual_task_settlement_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/types" "github.com/stretchr/testify/assert" @@ -94,6 +96,21 @@ func TestCompleteManualTaskBillingSettlementRefundsWalletAndReplays(t *testing.T assert.Equal(t, 160, getTokenRemainQuota(t, tokenID)) assert.EqualValues(t, 1, countLogs(t)) + // The current UI no longer submits an audit note. A retry of a settlement + // created by the previous UI must retain its durable note rather than fail + // solely because the generated default differs. + replayedWithoutNote, err := CompleteManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9001, + &actualQuota, + "", + ) + require.NoError(t, err) + assert.True(t, replayedWithoutNote.AlreadyApplied) + assert.EqualValues(t, -60, replayedWithoutNote.AppliedFundingDelta) + assert.EqualValues(t, 1, countLogs(t)) + differentQuota := int64(30) _, err = CompleteManualTaskBillingSettlement( manual.ID, @@ -124,7 +141,7 @@ func TestCompleteManualTaskBillingSettlementPreservesExplicitZero(t *testing.T) manual.Revision, 9011, &actualQuota, - "Verified that the provider produced no billable output.", + "", ) require.NoError(t, err) @@ -139,6 +156,279 @@ func TestCompleteManualTaskBillingSettlementPreservesExplicitZero(t *testing.T) assert.Zero(t, user.UsedQuota) assert.EqualValues(t, 1, user.RequestCount) assert.EqualValues(t, 1, countLogs(t)) + var storedManual model.BillingSettlement + require.NoError(t, model.DB.First(&storedManual, manual.ID).Error) + assert.Equal(t, "Administrator-approved manual task usage settlement", storedManual.ReconciliationReviewNote) +} + +func TestCompleteManualTaskBillingSettlementsZeroIsIdempotent(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 816, 817, 818 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-zero-batch", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + // The client model may be an alias while the persisted channel mapping + // records the effective MiniMax-H3 upstream model. + task.Properties.OriginModelName = "minimax-h3-alias" + task.Properties.UpstreamModelName = constant.TaskModelMiniMaxH3 + task.PrivateData.BillingContext.OriginModelName = "minimax-h3-alias" + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage requires manual review") + + result, err := CompleteManualTaskBillingSettlementsZero( + []model.BillingSettlementReviewTarget{{ID: manual.ID, Revision: manual.Revision}}, + 9016, + ) + require.NoError(t, err) + assert.Equal(t, 1, result.CompletedCount) + assert.Zero(t, result.FailedCount) + assert.Equal(t, []int64{manual.ID}, result.SettlementIDs) + assert.EqualValues(t, 1000, getUserQuota(t, userID)) + assert.Equal(t, 200, getTokenRemainQuota(t, tokenID)) + assert.Zero(t, getTokenUsedQuota(t, tokenID)) + + replay, err := CompleteManualTaskBillingSettlementsZero( + []model.BillingSettlementReviewTarget{{ID: manual.ID, Revision: manual.Revision}}, + 9016, + ) + require.NoError(t, err) + assert.Equal(t, 1, replay.CompletedCount) + assert.Zero(t, replay.FailedCount) + assert.EqualValues(t, 1000, getUserQuota(t, userID)) + assert.Equal(t, 200, getTokenRemainQuota(t, tokenID)) + assert.EqualValues(t, 1, countLogs(t)) + var completion model.BillingSettlement + require.NoError(t, model.DB.Where("operation_key = ?", model.BillingTaskManualCompletionOperationKey(task.ID)).First(&completion).Error) + var effect model.BillingSettlementEffect + require.NoError(t, common.Unmarshal([]byte(completion.EffectPayload), &effect)) + assert.Equal(t, manualTaskBillingZeroNote, effect.Content) +} + +func TestCompleteManualTaskBillingSettlementsAppliesDifferentExactQuotas(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 8171, 8172, 8173 + seedUser(t, userID, 2000) + seedToken(t, tokenID, userID, "manual-completion-exact-batch", 200) + seedChannel(t, channelID) + first := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + second := makeTask(userID, channelID, 80, tokenID, BillingSourceWallet, 0) + persistTask(t, first) + persistTask(t, second) + firstManual := createManualTaskFinalizeSettlement(t, first, "provider usage requires exact review") + secondManual := createManualTaskFinalizeSettlement(t, second, "provider usage requires exact review") + firstQuota := int64(25) + secondQuota := int64(60) + + result, err := CompleteManualTaskBillingSettlements([]ManualTaskBillingCompletionTarget{ + {ID: firstManual.ID, Revision: firstManual.Revision, ActualQuota: &firstQuota}, + {ID: secondManual.ID, Revision: secondManual.Revision, ActualQuota: &secondQuota}, + }, 9181) + + require.NoError(t, err) + assert.Equal(t, 2, result.CompletedCount) + assert.Zero(t, result.FailedCount) + assert.ElementsMatch(t, []int64{firstManual.ID, secondManual.ID}, result.SettlementIDs) + assert.EqualValues(t, 2095, getUserQuota(t, userID)) + assert.Equal(t, 295, getTokenRemainQuota(t, tokenID)) + assert.Equal(t, 105, getTokenUsedQuota(t, tokenID)) + var storedFirst, storedSecond model.Task + require.NoError(t, model.DB.First(&storedFirst, first.ID).Error) + require.NoError(t, model.DB.First(&storedSecond, second.ID).Error) + assert.Equal(t, 25, storedFirst.Quota) + assert.Equal(t, 60, storedSecond.Quota) + + replay, err := CompleteManualTaskBillingSettlements([]ManualTaskBillingCompletionTarget{ + {ID: firstManual.ID, Revision: firstManual.Revision, ActualQuota: &firstQuota}, + {ID: secondManual.ID, Revision: secondManual.Revision, ActualQuota: &secondQuota}, + }, 9181) + require.NoError(t, err) + assert.Equal(t, 2, replay.CompletedCount) + assert.Zero(t, replay.FailedCount) + assert.EqualValues(t, 2, countLogs(t)) +} + +func TestCompleteManualTaskBillingSettlementsPrevalidatesEveryItem(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 8181, 8182, 8183 + seedUser(t, userID, 2000) + seedToken(t, tokenID, userID, "manual-completion-exact-batch-validation", 200) + seedChannel(t, channelID) + first := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + second := makeTask(userID, channelID, 80, tokenID, BillingSourceWallet, 0) + persistTask(t, first) + persistTask(t, second) + firstManual := createManualTaskFinalizeSettlement(t, first, "provider usage requires exact review") + secondManual := createManualTaskFinalizeSettlement(t, second, "provider usage requires exact review") + firstQuota := int64(25) + invalidQuota := int64(81) + + result, err := CompleteManualTaskBillingSettlements([]ManualTaskBillingCompletionTarget{ + {ID: firstManual.ID, Revision: firstManual.Revision, ActualQuota: &firstQuota}, + {ID: secondManual.ID, Revision: secondManual.Revision, ActualQuota: &invalidQuota}, + }, 9182) + + require.NoError(t, err) + assert.Zero(t, result.CompletedCount) + assert.Equal(t, 2, result.FailedCount) + assert.Empty(t, result.SettlementIDs) + require.Len(t, result.Failed, 2) + assert.Equal(t, firstManual.ID, result.Failed[0].SettlementID) + assert.Equal(t, "not_attempted", result.Failed[0].Code) + assert.Equal(t, secondManual.ID, result.Failed[1].SettlementID) + assert.Equal(t, "invalid_settlement_request", result.Failed[1].Code) + assert.EqualValues(t, 2000, getUserQuota(t, userID)) + assert.Equal(t, 200, getTokenRemainQuota(t, tokenID)) + assert.Zero(t, countLogs(t)) +} + +func TestCompleteManualTaskBillingSettlementsPrevalidatesReplayReviewer(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 8184, 8185, 8186 + seedUser(t, userID, 2000) + seedToken(t, tokenID, userID, "manual-completion-replay-validation", 200) + seedChannel(t, channelID) + first := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + second := makeTask(userID, channelID, 80, tokenID, BillingSourceWallet, 0) + persistTask(t, first) + persistTask(t, second) + firstManual := createManualTaskFinalizeSettlement(t, first, "provider usage requires exact review") + secondManual := createManualTaskFinalizeSettlement(t, second, "provider usage requires exact review") + secondQuota := int64(40) + _, err := CompleteManualTaskBillingSettlement( + secondManual.ID, + secondManual.Revision, + 9300, + &secondQuota, + "Approved by the original reviewer.", + ) + require.NoError(t, err) + + firstQuota := int64(25) + result, err := CompleteManualTaskBillingSettlements([]ManualTaskBillingCompletionTarget{ + {ID: firstManual.ID, Revision: firstManual.Revision, ActualQuota: &firstQuota}, + {ID: secondManual.ID, Revision: secondManual.Revision, ActualQuota: &secondQuota}, + }, 9301) + + require.NoError(t, err) + assert.Zero(t, result.CompletedCount) + assert.Equal(t, 2, result.FailedCount) + assert.Empty(t, result.SettlementIDs) + require.Len(t, result.Failed, 2) + assert.Equal(t, firstManual.ID, result.Failed[0].SettlementID) + assert.Equal(t, "not_attempted", result.Failed[0].Code) + assert.Equal(t, secondManual.ID, result.Failed[1].SettlementID) + assert.Equal(t, "record_conflict", result.Failed[1].Code) + assert.EqualValues(t, 2040, getUserQuota(t, userID), "the earlier completed settlement is the only funding mutation") + var storedFirst model.Task + var storedSecond model.Task + require.NoError(t, model.DB.First(&storedFirst, first.ID).Error) + require.NoError(t, model.DB.First(&storedSecond, second.ID).Error) + assert.Equal(t, 100, storedFirst.Quota) + assert.Equal(t, 40, storedSecond.Quota) +} + +func TestValidateManualTaskBillingCompletionReplay(t *testing.T) { + original := model.BillingSettlement{ + ReconciliationReviewedBy: 9302, + ReconciliationReviewNote: "durable review note", + } + eligibility := manualTaskBillingEligibility{ + original: original, + originalAlreadyResolved: true, + } + + note, err := validateManualTaskBillingCompletionReplay( + eligibility, + 9302, + manualTaskBillingDefaultNote, + ) + require.NoError(t, err) + assert.Equal(t, original.ReconciliationReviewNote, note) + + _, err = validateManualTaskBillingCompletionReplay( + eligibility, + 9303, + manualTaskBillingDefaultNote, + ) + assert.ErrorIs(t, err, model.ErrBillingSettlementReviewConflict) + + eligibility.original.ReconciliationReviewNote = "" + _, err = validateManualTaskBillingCompletionReplay( + eligibility, + 9302, + manualTaskBillingDefaultNote, + ) + assert.ErrorIs(t, err, model.ErrBillingSettlementReviewConflict) +} + +func TestCompleteManualTaskBillingSettlementsZeroPrevalidatesSelection(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 819, 820, 821 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-zero-partial", 100) + seedChannel(t, channelID) + h3Task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + h3Task.Properties.OriginModelName = constant.TaskModelMiniMaxH3 + h3Task.Properties.UpstreamModelName = constant.TaskModelMiniMaxH3 + h3Task.PrivateData.BillingContext.OriginModelName = constant.TaskModelMiniMaxH3 + persistTask(t, h3Task) + h3Manual := createManualTaskFinalizeSettlement(t, h3Task, "provider usage requires manual review") + + ordinaryTask := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + ordinaryTask.Properties.OriginModelName = constant.TaskModelMiniMaxH3 + ordinaryTask.Properties.UpstreamModelName = "legacy-video-model" + persistTask(t, ordinaryTask) + ordinaryManual := createManualTaskFinalizeSettlement(t, ordinaryTask, "provider usage requires manual review") + + result, err := CompleteManualTaskBillingSettlementsZero( + []model.BillingSettlementReviewTarget{ + {ID: h3Manual.ID, Revision: h3Manual.Revision}, + {ID: ordinaryManual.ID, Revision: ordinaryManual.Revision}, + }, + 9019, + ) + require.NoError(t, err) + assert.Zero(t, result.CompletedCount) + assert.Equal(t, 2, result.FailedCount) + assert.Empty(t, result.SettlementIDs) + require.Len(t, result.Failed, 2) + assert.Equal(t, h3Manual.ID, result.Failed[0].SettlementID) + assert.Equal(t, "not_attempted", result.Failed[0].Code) + assert.Equal(t, ordinaryManual.ID, result.Failed[1].SettlementID) + assert.Contains(t, result.Failed[1].Message, "MiniMax-H3") + assert.EqualValues(t, 900, getUserQuota(t, userID), "no target may settle before the complete selection validates") + assert.Equal(t, 100, getTokenRemainQuota(t, tokenID)) +} + +func TestZeroTaskSettlementRechecksMiniMaxH3AtCompletionBoundary(t *testing.T) { + truncate(t) + const userID, tokenID, channelID = 825, 826, 827 + seedUser(t, userID, 900) + seedToken(t, tokenID, userID, "manual-completion-zero-boundary", 100) + seedChannel(t, channelID) + task := makeTask(userID, channelID, 100, tokenID, BillingSourceWallet, 0) + task.Properties.OriginModelName = constant.TaskModelMiniMaxH3 + task.Properties.UpstreamModelName = "legacy-video-model" + task.PrivateData.BillingContext.OriginModelName = constant.TaskModelMiniMaxH3 + persistTask(t, task) + manual := createManualTaskFinalizeSettlement(t, task, "provider usage requires manual review") + actualQuota := int64(0) + + _, err := completeManualTaskBillingSettlement( + manual.ID, + manual.Revision, + 9020, + &actualQuota, + manualTaskBillingZeroNote, + true, + ) + + assert.ErrorIs(t, err, model.ErrBillingSettlementReviewConflict) + assert.ErrorIs(t, err, errManualTaskBillingZeroQuotaRequiresMiniMaxH3) + assert.EqualValues(t, 900, getUserQuota(t, userID)) + assert.Equal(t, 100, getTokenRemainQuota(t, tokenID)) + assert.Equal(t, 100, getTokenUsedQuota(t, tokenID)) } func TestCompleteManualTaskBillingSettlementRefundsSubscriptionWithDeletedToken(t *testing.T) { diff --git a/service/smart_ops_alert.go b/service/smart_ops_alert.go index 46622b3b..d112eab8 100644 --- a/service/smart_ops_alert.go +++ b/service/smart_ops_alert.go @@ -21,6 +21,10 @@ import ( var ErrInvalidBillingSettlementReconciliationQuery = errors.New("invalid billing settlement reconciliation query") var ErrInvalidBillingSettlementReconciliationReview = errors.New("invalid billing settlement reconciliation review") +var errManualTaskBillingZeroQuotaRequiresMiniMaxH3 = errors.New("only MiniMax-H3 task settlements can use the zero-quota batch action") + +const manualTaskBillingDefaultNote = "Administrator-approved manual task usage settlement" +const manualTaskBillingZeroNote = "Administrator-confirmed zero final quota settlement" const ( smartOpsAlertStatusFiring = "firing" @@ -432,6 +436,48 @@ type ManualTaskBillingCompletionResult struct { AlreadyApplied bool `json:"already_applied"` } +type ManualTaskBillingBatchFailure struct { + SettlementID int64 `json:"settlement_id"` + Code string `json:"code"` + // Message is retained for older clients; new clients must translate Code. + Message string `json:"message,omitempty"` +} + +type ManualTaskBillingBatchCompletionResult struct { + CompletedCount int `json:"completed_count"` + FailedCount int `json:"failed_count"` + SettlementIDs []int64 `json:"settlement_ids"` + Failed []ManualTaskBillingBatchFailure `json:"failed"` +} + +// ManualTaskBillingCompletionTarget carries the immutable alert snapshot and +// the exact final quota supplied for that task. ActualQuota is a pointer so an +// omitted amount cannot be confused with an explicit zero-cost settlement. +type ManualTaskBillingCompletionTarget struct { + ID int64 + Revision int64 + ActualQuota *int64 +} + +func normalizeManualTaskBillingNote(note string) (string, error) { + note = strings.TrimSpace(note) + if note == "" { + note = manualTaskBillingDefaultNote + } + if err := validateBillingSettlementReviewNote(note); err != nil { + return "", err + } + note = strings.TrimSpace(common.SanitizePersistedLogContent(common.MaskSensitiveInfo(note))) + if err := validateBillingSettlementReviewNote(note); err != nil { + return "", err + } + return note, nil +} + +func isGeneratedManualTaskBillingNote(note string) bool { + return note == manualTaskBillingDefaultNote || note == manualTaskBillingZeroNote +} + // CompleteManualTaskBillingSettlement applies an administrator-approved exact // final task quota. It never charges above the frozen reservation: the only // allowed balance mutation is an exact refund (including an explicit zero-cost @@ -443,42 +489,303 @@ func CompleteManualTaskBillingSettlement( reviewerID int, actualQuota *int64, note string, +) (ManualTaskBillingCompletionResult, error) { + return completeManualTaskBillingSettlement(id, expectedRevision, reviewerID, actualQuota, note, false) +} + +func completeManualTaskBillingSettlement( + id int64, + expectedRevision int64, + reviewerID int, + actualQuota *int64, + note string, + requireMiniMaxH3 bool, ) (ManualTaskBillingCompletionResult, error) { if id <= 0 || expectedRevision <= 0 || reviewerID <= 0 || actualQuota == nil { return ManualTaskBillingCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview } - note = strings.TrimSpace(note) - if err := validateBillingSettlementReviewNote(note); err != nil { - return ManualTaskBillingCompletionResult{}, err + normalizedNote, noteErr := normalizeManualTaskBillingNote(note) + if noteErr != nil { + return ManualTaskBillingCompletionResult{}, noteErr } - note = strings.TrimSpace(common.SanitizePersistedLogContent(common.MaskSensitiveInfo(note))) - if err := validateBillingSettlementReviewNote(note); err != nil { + note = normalizedNote + + eligibility, err := prepareManualTaskBillingCompletion(id, expectedRevision, *actualQuota, requireMiniMaxH3, note) + if err != nil { return ManualTaskBillingCompletionResult{}, err } + original := eligibility.original + originalAlreadyResolved := eligibility.originalAlreadyResolved + validatedNote, replayErr := validateManualTaskBillingCompletionReplay(eligibility, reviewerID, note) + if replayErr != nil { + return ManualTaskBillingCompletionResult{}, replayErr + } + note = validatedNote + task := eligibility.task + input := eligibility.input - original, originalAlreadyResolved, err := model.GetManualTaskBillingSettlement(id, expectedRevision) + if _, err := model.EnsureManualTaskBillingCompletion(*input, reviewerID, note); err != nil { + return ManualTaskBillingCompletionResult{}, err + } + appliedFundingDelta, childAlreadyApplied, err := model.ApplyBillingSettlementOnce(*input) if err != nil { return ManualTaskBillingCompletionResult{}, err } - if originalAlreadyResolved && - (original.ReconciliationReviewedBy != reviewerID || original.ReconciliationReviewNote != note) { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + if appliedFundingDelta != input.FundingDelta { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementOperationConflict } - if *actualQuota < 0 || *actualQuota > original.TaskQuota || - original.TaskQuota < 0 || original.TaskQuotaTarget != original.TaskQuota || - original.FundingDelta != 0 || original.TokenDelta != 0 { - return ManualTaskBillingCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + settledTask, err := model.GetTaskByID(task.ID) + if err != nil { + return ManualTaskBillingCompletionResult{}, err } - if int64(int(original.TaskQuota)) != original.TaskQuota || int64(int(*actualQuota)) != *actualQuota { - return ManualTaskBillingCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + if int64(settledTask.Quota) != *actualQuota { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementTaskConflict + } + if _, originalResolvedByReplay, err := model.ResolveManualTaskBillingSettlement( + id, + expectedRevision, + reviewerID, + note, + ); err != nil { + return ManualTaskBillingCompletionResult{}, err + } else { + originalAlreadyResolved = originalAlreadyResolved || originalResolvedByReplay + } + + // Funding and task quota are already durable. Effect replay is independently + // idempotent and may remain pending without hiding the completed financial + // state or blocking the provider-driven terminal task transition. + if effectErr := model.ProcessBillingSettlementEffect(input.OperationKey); effectErr != nil { + common.SysLog(fmt.Sprintf("manual task billing settlement effect remains pending: operation=%s error=%v", input.OperationKey, effectErr)) + } + refreshBillingSettlementBacklogAfterReview() + return ManualTaskBillingCompletionResult{ + SettlementID: id, + TaskID: task.ID, + UserID: original.UserID, + OperationKey: input.OperationKey, + ActualQuota: *actualQuota, + AppliedFundingDelta: appliedFundingDelta, + AlreadyApplied: originalAlreadyResolved || eligibility.childWasApplied || childAlreadyApplied, + }, nil +} + +// CompleteManualTaskBillingSettlementsZero settles a bounded set of manual +// task-finalization records with an explicit final quota of zero. Each item +// uses the normal idempotent single-record path, so a failure or restart does +// not roll back or duplicate a different item in the same administrator action. +func CompleteManualTaskBillingSettlementsZero( + targets []model.BillingSettlementReviewTarget, + reviewerID int, +) (ManualTaskBillingBatchCompletionResult, error) { + if reviewerID <= 0 || len(targets) == 0 || len(targets) > 200 { + return ManualTaskBillingBatchCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + seen := make(map[int64]struct{}, len(targets)) + result := ManualTaskBillingBatchCompletionResult{ + SettlementIDs: make([]int64, 0, len(targets)), + Failed: make([]ManualTaskBillingBatchFailure, 0), + } + for _, target := range targets { + if target.ID <= 0 || target.Revision <= 0 { + return ManualTaskBillingBatchCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + if _, exists := seen[target.ID]; exists { + return ManualTaskBillingBatchCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + seen[target.ID] = struct{}{} + } + // Validate the complete selection before the first completion can mutate + // balances. A stale or non-H3 item therefore cannot leave earlier items + // settled while the batch request reports a later validation failure. + validationFailures := make(map[int64]string, len(targets)) + for _, target := range targets { + if err := validateManualTaskBillingZeroTarget(target, reviewerID); err != nil { + code := manualTaskBillingBatchErrorCode(err) + if code == "settlement_failed" { + common.SysError(fmt.Sprintf( + "manual task billing batch pre-validation failed: settlement_id=%d error=%s", + target.ID, + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(err.Error())), + )) + } + validationFailures[target.ID] = code + } + } + if len(validationFailures) > 0 { + for _, target := range targets { + code, failed := validationFailures[target.ID] + if !failed { + code = "not_attempted" + } + appendManualTaskBillingBatchFailure(&result, target.ID, code) + } + return result, nil + } + for _, target := range targets { + if _, err := completeManualTaskBillingSettlement( + target.ID, + target.Revision, + reviewerID, + ptrInt64(0), + manualTaskBillingZeroNote, + true, + ); err != nil { + code := manualTaskBillingBatchErrorCode(err) + if code == "settlement_failed" { + common.SysError(fmt.Sprintf( + "manual task billing batch settlement failed: settlement_id=%d error=%s", + target.ID, + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(err.Error())), + )) + } + appendManualTaskBillingBatchFailure(&result, target.ID, code) + continue + } + result.CompletedCount++ + result.SettlementIDs = append(result.SettlementIDs, target.ID) + } + return result, nil +} + +// CompleteManualTaskBillingSettlements applies administrator-supplied exact +// final quotas to a bounded set of task-finalization records. Validation of +// every item runs before the first balance mutation; each mutation then uses +// the existing idempotent single-record path and reports per-item failures. +func CompleteManualTaskBillingSettlements( + targets []ManualTaskBillingCompletionTarget, + reviewerID int, +) (ManualTaskBillingBatchCompletionResult, error) { + if reviewerID <= 0 || len(targets) == 0 || len(targets) > 200 { + return ManualTaskBillingBatchCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + seen := make(map[int64]struct{}, len(targets)) + for _, target := range targets { + if target.ID <= 0 || target.Revision <= 0 || target.ActualQuota == nil { + return ManualTaskBillingBatchCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + if _, exists := seen[target.ID]; exists { + return ManualTaskBillingBatchCompletionResult{}, ErrInvalidBillingSettlementReconciliationReview + } + seen[target.ID] = struct{}{} + } + result := ManualTaskBillingBatchCompletionResult{ + SettlementIDs: make([]int64, 0, len(targets)), + Failed: make([]ManualTaskBillingBatchFailure, 0), + } + validationFailures := make(map[int64]string, len(targets)) + for _, target := range targets { + eligibility, err := prepareManualTaskBillingCompletion(target.ID, target.Revision, *target.ActualQuota, false, manualTaskBillingDefaultNote) + if err == nil { + _, err = validateManualTaskBillingCompletionReplay(eligibility, reviewerID, manualTaskBillingDefaultNote) + } + if err != nil { + code := manualTaskBillingBatchErrorCode(err) + if code == "settlement_failed" { + common.SysError(fmt.Sprintf( + "manual task billing exact batch pre-validation failed: settlement_id=%d error=%s", + target.ID, + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(err.Error())), + )) + } + validationFailures[target.ID] = code + } + } + if len(validationFailures) > 0 { + for _, target := range targets { + code, failed := validationFailures[target.ID] + if !failed { + code = "not_attempted" + } + appendManualTaskBillingBatchFailure(&result, target.ID, code) + } + return result, nil + } + for _, target := range targets { + if _, err := completeManualTaskBillingSettlement( + target.ID, + target.Revision, + reviewerID, + target.ActualQuota, + manualTaskBillingDefaultNote, + false, + ); err != nil { + code := manualTaskBillingBatchErrorCode(err) + if code == "settlement_failed" { + common.SysError(fmt.Sprintf( + "manual task billing exact batch settlement failed: settlement_id=%d error=%s", + target.ID, + common.SanitizePersistedLogContent(common.MaskSensitiveInfo(err.Error())), + )) + } + appendManualTaskBillingBatchFailure(&result, target.ID, code) + continue + } + result.CompletedCount++ + result.SettlementIDs = append(result.SettlementIDs, target.ID) + } + return result, nil +} + +func ptrInt64(value int64) *int64 { + return &value +} + +func appendManualTaskBillingBatchFailure( + result *ManualTaskBillingBatchCompletionResult, + settlementID int64, + code string, +) { + result.Failed = append(result.Failed, ManualTaskBillingBatchFailure{ + SettlementID: settlementID, + Code: code, + Message: manualTaskBillingBatchErrorMessage(code), + }) + result.FailedCount = len(result.Failed) +} + +type manualTaskBillingEligibility struct { + original model.BillingSettlement + originalAlreadyResolved bool + task *model.Task + input *model.BillingSettlementInput + childWasApplied bool +} + +// prepareManualTaskBillingCompletion validates the immutable task settlement +// snapshot and builds the exact child input before any balance mutation. Both +// single-record and batch callers use this same financial eligibility contract. +func prepareManualTaskBillingCompletion( + id int64, + expectedRevision int64, + actualQuota int64, + requireMiniMaxH3 bool, + note string, +) (manualTaskBillingEligibility, error) { + original, originalAlreadyResolved, err := model.GetManualTaskBillingSettlement(id, expectedRevision) + if err != nil { + return manualTaskBillingEligibility{}, err + } + if actualQuota < 0 || actualQuota > original.TaskQuota || + original.TaskQuota < 0 || original.TaskQuotaTarget != original.TaskQuota || + original.FundingDelta != 0 || original.TokenDelta != 0 || + int64(int(original.TaskQuota)) != original.TaskQuota || + int64(int(actualQuota)) != actualQuota { + return manualTaskBillingEligibility{}, ErrInvalidBillingSettlementReconciliationReview } task, err := model.GetTaskByID(original.TaskID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + return manualTaskBillingEligibility{}, model.ErrBillingSettlementReviewConflict } - return ManualTaskBillingCompletionResult{}, err + return manualTaskBillingEligibility{}, err + } + if requireMiniMaxH3 && !model.IsMiniMaxH3Task(task) { + return manualTaskBillingEligibility{}, errors.Join( + model.ErrBillingSettlementReviewConflict, + errManualTaskBillingZeroQuotaRequiresMiniMaxH3, + ) } expectedSource := model.BillingSettlementSourceWallet if taskIsSubscription(task) { @@ -489,20 +796,20 @@ func CompleteManualTaskBillingSettlement( original.SubscriptionID != task.PrivateData.SubscriptionId || original.TokenID != task.PrivateData.TokenId || original.SubscriptionPreConsumeRequestID != task.PrivateData.BillingRequestId { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + return manualTaskBillingEligibility{}, model.ErrBillingSettlementReviewConflict } existingChild, childFound, err := model.GetManualTaskBillingCompletion(task.ID) if err != nil { - return ManualTaskBillingCompletionResult{}, err + return manualTaskBillingEligibility{}, err } childWasApplied := childFound && existingChild.Status == model.BillingSettlementStatusApplied - if childFound && existingChild.TaskQuotaTarget != *actualQuota { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + if childFound && existingChild.TaskQuotaTarget != actualQuota { + return manualTaskBillingEligibility{}, model.ErrBillingSettlementReviewConflict } if int64(task.Quota) != original.TaskQuota && - (int64(task.Quota) != *actualQuota || !childWasApplied) { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + (int64(task.Quota) != actualQuota || !childWasApplied) { + return manualTaskBillingEligibility{}, model.ErrBillingSettlementReviewConflict } completionTask := *task @@ -513,59 +820,111 @@ func CompleteManualTaskBillingSettlement( } input := buildTaskExactFinalSettlementInput( &completionTask, - int(*actualQuota), + int(actualQuota), usage, - "Administrator-approved manual task usage settlement", + note, ) if input == nil { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + return manualTaskBillingEligibility{}, model.ErrBillingSettlementReviewConflict + } + if completionTask.PrivateData.BillingContext != nil { + attachTaskUsageEnvelopeMetadata(input, completionTask.PrivateData.BillingContext.TaskUsageEnvelope) } input.OperationKey = model.BillingTaskManualCompletionOperationKey(task.ID) - if _, err := model.EnsureManualTaskBillingCompletion(*input, reviewerID, note); err != nil { - return ManualTaskBillingCompletionResult{}, err + return manualTaskBillingEligibility{ + original: original, + originalAlreadyResolved: originalAlreadyResolved, + task: task, + input: input, + childWasApplied: childWasApplied, + }, nil +} + +// validateManualTaskBillingCompletionReplay keeps the batch pre-validation +// contract identical to the single-item replay path. A generated note may be +// replaced by the durable note on replay, but a different reviewer or a +// missing durable note is always a conflict. +func validateManualTaskBillingCompletionReplay( + eligibility manualTaskBillingEligibility, + reviewerID int, + note string, +) (string, error) { + if !eligibility.originalAlreadyResolved { + return note, nil } - appliedFundingDelta, childAlreadyApplied, err := model.ApplyBillingSettlementOnce(*input) - if err != nil { - return ManualTaskBillingCompletionResult{}, err + original := eligibility.original + if original.ReconciliationReviewedBy != reviewerID { + return "", model.ErrBillingSettlementReviewConflict } - if appliedFundingDelta != input.FundingDelta { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementOperationConflict + if note != original.ReconciliationReviewNote { + if !isGeneratedManualTaskBillingNote(note) || strings.TrimSpace(original.ReconciliationReviewNote) == "" { + return "", model.ErrBillingSettlementReviewConflict + } + note = original.ReconciliationReviewNote } - settledTask, err := model.GetTaskByID(task.ID) + return note, nil +} + +// validateManualTaskBillingZeroTarget performs reviewer-specific checks after +// the shared completion eligibility contract has been evaluated. +func validateManualTaskBillingZeroTarget( + target model.BillingSettlementReviewTarget, + reviewerID int, +) error { + eligibility, err := prepareManualTaskBillingCompletion(target.ID, target.Revision, 0, true, manualTaskBillingZeroNote) if err != nil { - return ManualTaskBillingCompletionResult{}, err - } - if int64(settledTask.Quota) != *actualQuota { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementTaskConflict + return err } - if _, originalResolvedByReplay, err := model.ResolveManualTaskBillingSettlement( - id, - expectedRevision, - reviewerID, - note, - ); err != nil { - return ManualTaskBillingCompletionResult{}, err - } else { - originalAlreadyResolved = originalAlreadyResolved || originalResolvedByReplay + _, err = validateManualTaskBillingCompletionReplay(eligibility, reviewerID, manualTaskBillingZeroNote) + return err +} + +func manualTaskBillingBatchErrorCode(err error) string { + switch { + case errors.Is(err, ErrInvalidBillingSettlementReconciliationReview): + return "invalid_settlement_request" + case errors.Is(err, errManualTaskBillingZeroQuotaRequiresMiniMaxH3): + return "minimax_h3_required" + case errors.Is(err, model.ErrBillingSettlementReviewConflict), + errors.Is(err, model.ErrBillingSettlementTaskConflict), + errors.Is(err, model.ErrBillingSettlementOperationConflict): + return "record_conflict" + case errors.Is(err, model.ErrBillingSettlementManualReview): + return "manual_review_required" + case errors.Is(err, model.ErrTokenQuotaInsufficient): + return "token_quota_inconsistent" + case errors.Is(err, model.ErrSubscriptionRefundClamped): + return "subscription_refund_clamped" + case errors.Is(err, model.ErrSubscriptionSettlementUnbound), + errors.Is(err, model.ErrSubscriptionSettlementPeriodChanged): + return "subscription_reservation_invalid" + default: + return "settlement_failed" } +} - // Funding and task quota are already durable. Effect replay is independently - // idempotent and may remain pending without hiding the completed financial - // state or blocking the provider-driven terminal task transition. - if effectErr := model.ProcessBillingSettlementEffect(input.OperationKey); effectErr != nil { - common.SysLog(fmt.Sprintf("manual task billing settlement effect remains pending: operation=%s error=%v", input.OperationKey, effectErr)) +func manualTaskBillingBatchErrorMessage(code string) string { + switch code { + case "not_attempted": + return "not attempted because another selected settlement failed validation" + case "invalid_settlement_request": + return "the supplied final quota or the settlement snapshot is invalid for this record" + case "minimax_h3_required": + return "only MiniMax-H3 task settlements can use the zero-quota batch action" + case "record_conflict": + return "record changed or could not be applied safely; refresh and reconcile it" + case "manual_review_required": + return "record still requires a manual financial review" + case "token_quota_inconsistent": + return "the token quota mirror is inconsistent; repair the token record" + case "subscription_refund_clamped": + return "the subscription usage mirror is lower than the refund" + case "subscription_reservation_invalid": + return "the subscription reservation is unbound or its period changed" + default: + return "manual task billing settlement failed; inspect the reconciliation record" } - refreshBillingSettlementBacklogAfterReview() - return ManualTaskBillingCompletionResult{ - SettlementID: id, - TaskID: task.ID, - UserID: original.UserID, - OperationKey: input.OperationKey, - ActualQuota: *actualQuota, - AppliedFundingDelta: appliedFundingDelta, - AlreadyApplied: originalAlreadyResolved || childWasApplied || childAlreadyApplied, - }, nil } type smartOpsAlertRecipient struct { diff --git a/service/task_billing.go b/service/task_billing.go index 93a40b87..58dfa974 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -11,6 +11,7 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/setting/ratio_setting" "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" @@ -153,6 +154,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} { if bc.TaskUsage != nil { other["task_usage"] = taskUsageLogMetadata(bc.TaskUsage) } + if bc.TaskUsageEnvelope != nil { + other["task_usage_envelope"] = taskUsageEnvelopeLogMetadata(bc.TaskUsageEnvelope) + } } props := task.Properties if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName { @@ -199,9 +203,31 @@ func taskUsageLogMetadata(usage *types.TaskUsage) map[string]interface{} { if usage.InputAudioCount != nil { metadata["input_audio_count"] = *usage.InputAudioCount } + if usage.CompletionTokens != nil { + metadata["completion_tokens"] = *usage.CompletionTokens + } + if usage.TotalTokens != nil { + metadata["total_tokens"] = *usage.TotalTokens + } return metadata } +func taskUsageEnvelopeLogMetadata(envelope *types.TaskUsageEnvelope) map[string]interface{} { + if envelope == nil { + return nil + } + return map[string]interface{}{ + "producer_kind": envelope.ProducerKind, + "source_id": envelope.SourceID, + "schema_version": envelope.SchemaVersion, + "contract_digest": envelope.ContractDigest, + "stage": envelope.Stage, + "presence": envelope.Presence, + "completeness": envelope.Completeness, + "evidence_digest": envelope.EvidenceDigest, + } +} + func taskSubmissionUsageIsDeferred(info *relaycommon.RelayInfo) bool { return info != nil && info.TaskBillingPlan != nil } @@ -350,10 +376,11 @@ func buildTaskFinalSettlementInput(task *model.Task, actualQuota int, reason str } type taskTerminalBillingDecision struct { - Settlement *model.BillingSettlementInput - ManualReason string - Usage *types.TaskUsage - UsesPlan bool + Settlement *model.BillingSettlementInput + ManualReason string + Usage *types.TaskUsage + UsageEnvelope *types.TaskUsageEnvelope + UsesPlan bool } func prepareTaskTerminalBillingDecision( @@ -374,11 +401,21 @@ func prepareTaskTerminalBillingDecision( } } - usage := frozenTaskUsage(task, taskResult) + var usageEnvelope *types.TaskUsageEnvelope + if taskBillingPlanHasUsageIdentity(plan) { + usageEnvelope = frozenTaskUsageEnvelope(task, taskResult) + } + usage := frozenTaskUsage( + task, + taskResult, + usageEnvelope, + taskBillingPlanHasUsageIdentity(plan), + ) decision := taskTerminalBillingDecision{ - Settlement: buildTaskManualSettlementInput(task), - Usage: usage, - UsesPlan: true, + Settlement: buildTaskManualSettlementInput(task), + Usage: usage, + UsageEnvelope: usageEnvelope, + UsesPlan: true, } if task == nil || task.ID <= 0 || task.Quota < 0 { decision.ManualReason = "H3 terminal billing task snapshot is invalid" @@ -392,6 +429,10 @@ func prepareTaskTerminalBillingDecision( decision.ManualReason = "H3 terminal billing frozen totals require manual reconciliation: " + err.Error() return decision } + if err := validateTaskUsageEnvelopeForPlan(plan, usageEnvelope); err != nil { + decision.ManualReason = "H3 terminal usage requires manual reconciliation: usage contract: " + err.Error() + return decision + } quote, err := task_billing_setting.QuoteH3Final(plan, usage) if err != nil { decision.ManualReason = "H3 terminal usage requires manual reconciliation: " + err.Error() @@ -406,6 +447,7 @@ func prepareTaskTerminalBillingDecision( status = strings.ToLower(strings.TrimSpace(taskResult.Status)) } decision.Settlement = buildTaskExactFinalSettlementInput(task, quote.Quota, usage, "H3 actual usage settlement (provider status: "+status+")") + attachTaskUsageEnvelopeMetadata(decision.Settlement, usageEnvelope) return decision } @@ -416,7 +458,50 @@ func taskBillingPlan(task *model.Task) *types.TaskBillingPlan { return task.PrivateData.BillingContext.TaskBillingPlan } -func frozenTaskUsage(task *model.Task, taskResult *relaycommon.TaskInfo) *types.TaskUsage { +func frozenTaskUsageEnvelope(task *model.Task, taskResult *relaycommon.TaskInfo) *types.TaskUsageEnvelope { + if task != nil && task.PrivateData.BillingContext != nil && task.PrivateData.BillingContext.TaskUsageEnvelope != nil { + frozen := task.PrivateData.BillingContext.TaskUsageEnvelope + if frozen.Completeness == types.TaskUsageCompletenessMissing && + taskResult != nil && taskResult.UsageEnvelope != nil && + taskResult.UsageEnvelope.Stage == types.TaskUsageSourceProviderResponse && + taskResult.UsageEnvelope.Completeness == types.TaskUsageCompletenessComplete { + candidate := types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + if err := validateTaskUsageEnvelopeForPlan(taskBillingPlan(task), candidate); err == nil { + return candidate + } + } + return types.CloneTaskUsageEnvelope(frozen) + } + if taskResult != nil && taskResult.UsageEnvelope != nil { + candidate := types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + if err := validateTaskUsageEnvelopeForPlan(taskBillingPlan(task), candidate); err == nil { + return candidate + } + } + return nil +} + +func frozenTaskUsage( + task *model.Task, + taskResult *relaycommon.TaskInfo, + envelope *types.TaskUsageEnvelope, + requireValidatedEnvelope bool, +) *types.TaskUsage { + if envelope != nil { + if usage := types.CloneTaskUsage(envelope.Usage); usage != nil { + return usage + } + return &types.TaskUsage{ + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessMissing, + } + } + if requireValidatedEnvelope { + return &types.TaskUsage{ + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessMissing, + } + } if task != nil && task.PrivateData.BillingContext != nil && task.PrivateData.BillingContext.TaskUsage != nil { frozen := task.PrivateData.BillingContext.TaskUsage // A terminal poll may initially lack usage and create a manual hold while @@ -441,6 +526,55 @@ func frozenTaskUsage(task *model.Task, taskResult *relaycommon.TaskInfo) *types. } } +func validateTaskUsageEnvelopeForPlan(plan *types.TaskBillingPlan, envelope *types.TaskUsageEnvelope) error { + if plan == nil { + return fmt.Errorf("task billing plan is required") + } + if !taskBillingPlanHasUsageIdentity(plan) { + return nil + } + if envelope == nil { + return fmt.Errorf("usage envelope is missing") + } + if envelope.ProducerKind != plan.UsageProducerKind || + envelope.SourceID != plan.UsageSourceID || + envelope.SchemaVersion != plan.UsageSchemaVersion || + envelope.ContractDigest != plan.UsageContractDigest { + return fmt.Errorf("usage envelope identity does not match the frozen plan") + } + if envelope.Stage != types.TaskUsageSourceProviderResponse { + return fmt.Errorf("usage envelope stage must be %q", types.TaskUsageSourceProviderResponse) + } + contract, err := taskusage.ResolveContract(plan.UsageSourceID, plan.UsageSchemaVersion, plan.UsageContractDigest) + if err != nil { + return fmt.Errorf("resolve frozen usage contract: %w", err) + } + if err := taskusage.ValidateEnvelope(contract, envelope, plan.UsageProducerKind); err != nil { + return fmt.Errorf("usage envelope validation failed: %w", err) + } + return nil +} + +func taskBillingPlanHasUsageIdentity(plan *types.TaskBillingPlan) bool { + if plan == nil { + return false + } + return plan.UsageProducerKind != "" || + plan.UsageSourceID != "" || + plan.UsageSchemaVersion != 0 || + plan.UsageContractDigest != "" +} + +func attachTaskUsageEnvelopeMetadata(input *model.BillingSettlementInput, envelope *types.TaskUsageEnvelope) { + if input == nil || input.Effect == nil || envelope == nil { + return + } + if input.Effect.Other == nil { + input.Effect.Other = make(map[string]interface{}) + } + input.Effect.Other["task_usage_envelope"] = taskUsageEnvelopeLogMetadata(envelope) +} + func buildTaskManualSettlementInput(task *model.Task) *model.BillingSettlementInput { if task == nil || task.ID <= 0 { return nil diff --git a/service/task_billing_h3_test.go b/service/task_billing_h3_test.go index ca0f76f8..dd0045ce 100644 --- a/service/task_billing_h3_test.go +++ b/service/task_billing_h3_test.go @@ -13,6 +13,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/setting/task_billing_setting" "github.com/MAX-API-Next/MAX-API/types" @@ -37,7 +38,26 @@ func (a *h3TerminalPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInf if a.parseErr != nil { return nil, a.parseErr } - return a.result, nil + if a.result == nil || a.result.UsageEnvelope != nil { + return a.result, nil + } + result := *a.result + usage := types.CloneTaskUsage(result.Usage) + if usage != nil && usage.Source == "" { + usage.Source = types.TaskUsageSourceProviderResponse + } + envelope, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceProviderResponse, + usage, + ) + if err != nil { + return nil, err + } + result.Usage = usage + result.UsageEnvelope = envelope + return &result, nil } func (a *h3TerminalPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { @@ -48,6 +68,22 @@ func h3UsageInt64(value int64) *int64 { return &value } +func serviceH3TaskInfo(t *testing.T, status string, usage *types.TaskUsage) *relaycommon.TaskInfo { + t.Helper() + cloned := types.CloneTaskUsage(usage) + if cloned != nil && cloned.Source == "" { + cloned.Source = types.TaskUsageSourceProviderResponse + } + envelope, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceProviderResponse, + cloned, + ) + require.NoError(t, err) + return &relaycommon.TaskInfo{Status: status, Usage: cloned, UsageEnvelope: envelope} +} + func buildServiceH3Plan(t *testing.T, input task_billing_setting.H3BillingInput) *types.TaskBillingPlan { t.Helper() originalQuotaPerUnit := common.QuotaPerUnit @@ -86,7 +122,7 @@ func TestH3TerminalDecisionUsesFrozenPlanForNegativeAndZeroDelta(t *testing.T) { decision := prepareTaskTerminalBillingDecision( context.Background(), nil, task, - &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: usage}, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax, ) @@ -109,7 +145,7 @@ func TestH3TerminalDecisionUsesFrozenPlanForNegativeAndZeroDelta(t *testing.T) { reserveUsage.InputVideoDurationMs = h3UsageInt64(15_000) zeroDeltaDecision := prepareTaskTerminalBillingDecision( context.Background(), nil, zeroDeltaTask, - &relaycommon.TaskInfo{Status: string(model.TaskStatusFailure), Usage: reserveUsage}, + serviceH3TaskInfo(t, string(model.TaskStatusFailure), reserveUsage), constant.ChannelTypeMiniMax, ) require.True(t, zeroDeltaDecision.UsesPlan) @@ -119,6 +155,22 @@ func TestH3TerminalDecisionUsesFrozenPlanForNegativeAndZeroDelta(t *testing.T) { require.EqualValues(t, plan.ReserveQuota, zeroDeltaDecision.Settlement.TaskQuotaTarget) } +func TestPrepareTaskTerminalBillingDecisionKeepsMissingUsageSentinel(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), nil), + constant.ChannelTypeMiniMax, + ) + + require.NotNil(t, decision.Usage) + require.Equal(t, types.TaskUsageCompletenessMissing, decision.Usage.Completeness) + require.NotEmpty(t, decision.ManualReason) +} + func TestH3TerminalDecisionPreservesExplicitZeroFinalQuota(t *testing.T) { plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ Resolution: "768P", OutputDurationSeconds: 5, @@ -138,7 +190,7 @@ func TestH3TerminalDecisionPreservesExplicitZeroFinalQuota(t *testing.T) { decision := prepareTaskTerminalBillingDecision( context.Background(), nil, task, - &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: usage}, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax, ) @@ -152,6 +204,240 @@ func TestH3TerminalDecisionPreservesExplicitZeroFinalQuota(t *testing.T) { require.Zero(t, decision.Settlement.Effect.Quota) } +func TestH3TerminalDecisionFailsClosedOnUsageEnvelopeIdentityDrift(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + result := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage) + result.UsageEnvelope.SourceID = "unexpected-source" + + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, result, constant.ChannelTypeMiniMax) + require.True(t, decision.UsesPlan) + require.True(t, strings.HasPrefix(decision.ManualReason, "H3 terminal usage requires manual reconciliation:")) + // The invalid first candidate is filtered before terminal validation, so the + // decision must fail closed on the absence of a plan-validated envelope. + require.ErrorContains(t, errors.New(decision.ManualReason), "usage envelope is missing") + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) + require.Nil(t, decision.Settlement.Effect) + require.Nil(t, decision.UsageEnvelope) + require.NotNil(t, decision.Usage) + require.Equal(t, types.TaskUsageCompletenessMissing, decision.Usage.Completeness) +} + +func TestFrozenTaskUsageEnvelopeKeepsMissingUntilReplacementMatchesPlan(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + missing, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceProviderResponse, + nil, + ) + require.NoError(t, err) + task.PrivateData.BillingContext.TaskUsageEnvelope = missing + + completeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + mismatched := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage).UsageEnvelope + mismatched.SourceID = "unexpected-source" + selected := frozenTaskUsageEnvelope(task, &relaycommon.TaskInfo{UsageEnvelope: mismatched}) + require.Equal(t, missing.EvidenceDigest, selected.EvidenceDigest) + require.Equal(t, types.TaskUsageCompletenessMissing, selected.Completeness) + + valid := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage).UsageEnvelope + selected = frozenTaskUsageEnvelope(task, &relaycommon.TaskInfo{UsageEnvelope: valid}) + require.Equal(t, valid.EvidenceDigest, selected.EvidenceDigest) + require.Equal(t, types.TaskUsageCompletenessComplete, selected.Completeness) +} + +func TestFrozenTaskUsageEnvelopeRejectsInvalidFirstCandidateUntilValidPoll(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + completeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), + InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + } + + mismatched := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage).UsageEnvelope + mismatched.SourceID = "unexpected-source" + selected := frozenTaskUsageEnvelope(task, &relaycommon.TaskInfo{UsageEnvelope: mismatched}) + require.Nil(t, selected) + mismatchDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: completeUsage, UsageEnvelope: mismatched, + }, constant.ChannelTypeMiniMax) + require.NotEmpty(t, mismatchDecision.ManualReason) + require.Nil(t, mismatchDecision.UsageEnvelope) + + valid := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage).UsageEnvelope + selected = frozenTaskUsageEnvelope(task, &relaycommon.TaskInfo{UsageEnvelope: valid}) + require.NotNil(t, selected) + require.Equal(t, valid.EvidenceDigest, selected.EvidenceDigest) + require.Equal(t, types.TaskUsageCompletenessComplete, selected.Completeness) +} + +func TestH3HistoricalPlanWithoutUsageIdentityKeepsLegacyCompatibility(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + plan.UsageProducerKind = "" + plan.UsageSourceID = "" + plan.UsageSchemaVersion = 0 + plan.UsageContractDigest = "" + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, + }, constant.ChannelTypeMiniMax) + require.True(t, decision.UsesPlan) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + require.Nil(t, decision.UsageEnvelope) +} + +func TestH3HistoricalPlanKeepsPersistedLegacyUsageWhenNewResultHasEnvelope(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + plan.UsageProducerKind = "" + plan.UsageSourceID = "" + plan.UsageSchemaVersion = 0 + plan.UsageContractDigest = "" + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + task.PrivateData.BillingContext.TaskUsage = &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + newResultUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(15_000), InputImageCount: h3UsageInt64(9), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), newResultUsage), + constant.ChannelTypeMiniMax, + ) + require.Empty(t, decision.ManualReason) + require.Nil(t, decision.UsageEnvelope) + require.EqualValues(t, 5_000, *decision.Usage.OutputDurationMs) + require.EqualValues(t, 0, *decision.Usage.InputImageCount) +} + +func TestH3TerminalDecisionRejectsNonProviderUsageStage(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceManualReconcile, Completeness: types.TaskUsageCompletenessComplete, + } + envelope, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceManualReconcile, + usage, + ) + require.NoError(t, err) + + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ + Status: string(model.TaskStatusSuccess), Usage: usage, UsageEnvelope: envelope, + }, constant.ChannelTypeMiniMax) + // Non-provider evidence is rejected at the freezing boundary and therefore + // reaches terminal billing as a missing validated envelope. + require.ErrorContains(t, errors.New(decision.ManualReason), "usage envelope is missing") + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) + require.Nil(t, decision.UsageEnvelope) + require.NotNil(t, decision.Usage) + require.Equal(t, types.TaskUsageCompletenessMissing, decision.Usage.Completeness) +} + +func TestH3TerminalDecisionUsesValidatedEnvelopeOverDivergentCompatibilityUsage(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + envelopeUsage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + envelope, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.MiniMaxH3Contract(), + types.TaskUsageSourceProviderResponse, + envelopeUsage, + ) + require.NoError(t, err) + task.PrivateData.BillingContext.TaskUsageEnvelope = envelope + task.PrivateData.BillingContext.TaskUsage = &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(15_000), InputImageCount: h3UsageInt64(9), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), task.PrivateData.BillingContext.TaskUsage), + constant.ChannelTypeMiniMax, + ) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Usage) + require.EqualValues(t, 5_000, *decision.Usage.OutputDurationMs) + require.EqualValues(t, 0, *decision.Usage.InputImageCount) + expected, err := task_billing_setting.QuoteH3Final(plan, envelopeUsage) + require.NoError(t, err) + require.EqualValues(t, expected.Quota, decision.Settlement.TaskQuotaTarget) +} + +func TestH3SettlementEffectStoresOnlyCanonicalEnvelopeMetadata(t *testing.T) { + plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }) + task := makeServiceH3Task(t, plan.ReserveQuota, plan) + usage := &types.TaskUsage{ + OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + } + result := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage) + result.Url = "https://cdn.example.com/private-result.mp4" + + decision := prepareTaskTerminalBillingDecision( + context.Background(), nil, task, result, constant.ChannelTypeMiniMax, + ) + require.Empty(t, decision.ManualReason) + require.NotNil(t, decision.Settlement) + require.NotNil(t, decision.Settlement.Effect) + metadata, ok := decision.Settlement.Effect.Other["task_usage_envelope"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, types.TaskUsageCompletenessComplete, metadata["completeness"]) + require.NotContains(t, metadata, "usage") + require.NotContains(t, metadata, "payload") + require.NotContains(t, metadata, "url") + encoded, err := common.Marshal(decision.Settlement.Effect.Other) + require.NoError(t, err) + require.NotContains(t, string(encoded), "private-result.mp4") +} + func TestH3TerminalDecisionRoutesUnsafeUsageToManual(t *testing.T) { plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ Resolution: "768P", OutputDurationSeconds: 5, InputVideoCount: 1, @@ -161,7 +447,7 @@ func TestH3TerminalDecisionRoutesUnsafeUsageToManual(t *testing.T) { usage *types.TaskUsage }{ {name: "missing", usage: nil}, - {name: "partial", usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessPartial}}, + {name: "partial", usage: &types.TaskUsage{OutputDurationMs: h3UsageInt64(5_000), Completeness: types.TaskUsageCompletenessPartial}}, {name: "invalid", usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessInvalid}}, {name: "ambiguous", usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessAmbiguous}}, } @@ -170,7 +456,7 @@ func TestH3TerminalDecisionRoutesUnsafeUsageToManual(t *testing.T) { task := makeServiceH3Task(t, plan.ReserveQuota, plan) decision := prepareTaskTerminalBillingDecision( context.Background(), nil, task, - &relaycommon.TaskInfo{Status: string(model.TaskStatusFailure), Usage: test.usage}, + serviceH3TaskInfo(t, string(model.TaskStatusFailure), test.usage), constant.ChannelTypeMiniMax, ) require.True(t, decision.UsesPlan) @@ -199,7 +485,7 @@ func TestH3TerminalDecisionRoutesChangedFrozenTotalsToManual(t *testing.T) { decision := prepareTaskTerminalBillingDecision( context.Background(), nil, task, - &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess), Usage: usage}, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax, ) @@ -370,10 +656,9 @@ func TestUpdateVideoSingleTaskRecoversManualH3WhenLaterPollHasCompleteUsage(t *t task := makeServiceH3Task(t, plan.ReserveQuota, plan) task.Status = model.TaskStatusInProgress persistTask(t, task) - missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), - Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, - }, constant.ChannelTypeMiniMax) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}), + constant.ChannelTypeMiniMax) require.NotEmpty(t, missingDecision.ManualReason) won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) require.NoError(t, err) @@ -427,10 +712,9 @@ func TestRecoverManualH3PersistsTerminalEvidenceBeforeFunding(t *testing.T) { task := makeServiceH3Task(t, plan.ReserveQuota, plan) persistTask(t, task) - missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), - Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, - }, constant.ChannelTypeMiniMax) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}), + constant.ChannelTypeMiniMax) require.NotEmpty(t, missingDecision.ManualReason) won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) require.NoError(t, err) @@ -442,15 +726,11 @@ func TestRecoverManualH3PersistsTerminalEvidenceBeforeFunding(t *testing.T) { Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: completeUsage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage), constant.ChannelTypeMiniMax) require.Empty(t, decision.ManualReason) - providerResult := &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), - Url: "https://cdn.example.com/manual-recovered.mp4", - Usage: completeUsage, - } + providerResult := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage) + providerResult.Url = "https://cdn.example.com/manual-recovered.mp4" expectedUpdatedAt := task.UpdatedAt recovered, err := recoverManualTaskBillingSettlement( context.Background(), task, decision, providerResult, model.TaskStatusInProgress, expectedUpdatedAt, @@ -485,10 +765,9 @@ func TestUpdateVideoSingleTaskAppliesAlreadyPromotedManualH3Settlement(t *testin task.Status = model.TaskStatusInProgress persistTask(t, task) - missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), - Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, - }, constant.ChannelTypeMiniMax) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}), + constant.ChannelTypeMiniMax) require.NotEmpty(t, missingDecision.ManualReason) won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) require.NoError(t, err) @@ -500,9 +779,8 @@ func TestUpdateVideoSingleTaskAppliesAlreadyPromotedManualH3Settlement(t *testin Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - providerResult := &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Url: "https://cdn.example.com/promoted-recovery.mp4", Usage: completeUsage, - } + providerResult := serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage) + providerResult.Url = "https://cdn.example.com/promoted-recovery.mp4" decision := prepareTaskTerminalBillingDecision( context.Background(), nil, task, providerResult, constant.ChannelTypeMiniMax, ) @@ -513,6 +791,7 @@ func TestUpdateVideoSingleTaskAppliesAlreadyPromotedManualH3Settlement(t *testin billingContext := *task.PrivateData.BillingContext candidate.PrivateData.BillingContext = &billingContext candidate.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(completeUsage) + candidate.PrivateData.BillingContext.TaskUsageEnvelope = types.CloneTaskUsageEnvelope(decision.UsageEnvelope) persistPendingTaskTerminalEvidence(&candidate, providerResult, time.Now().Unix()) won, err = candidate.UpdateWithStatusAndPendingTerminalEvidence(task.Status, task.UpdatedAt) require.NoError(t, err) @@ -596,9 +875,9 @@ func TestUpdateVideoSingleTaskRecoversManualH3SubscriptionFullRefund(t *testing. task.Status = model.TaskStatusInProgress persistTask(t, task) - missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, - }, constant.ChannelTypeMiniMax) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}), + constant.ChannelTypeMiniMax) require.NotEmpty(t, missingDecision.ManualReason) won, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) require.NoError(t, err) @@ -610,9 +889,8 @@ func TestUpdateVideoSingleTaskRecoversManualH3SubscriptionFullRefund(t *testing. Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: completeUsage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), completeUsage), constant.ChannelTypeMiniMax) require.Empty(t, decision.ManualReason) require.NotNil(t, decision.Settlement) require.EqualValues(t, -100, decision.Settlement.FundingDelta) @@ -660,9 +938,9 @@ func TestUpdateVideoSingleTaskDoesNotRecoverManualH3BeforeSubmissionSettlement(t OperationKey: requestKey, Source: model.BillingSettlementSourceWallet, UserID: task.UserId, Status: model.BillingSettlementStatusPending, CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), Revision: 1, }).Error) - missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}, - }, constant.ChannelTypeMiniMax) + missingDecision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), &types.TaskUsage{Completeness: types.TaskUsageCompletenessMissing}), + constant.ChannelTypeMiniMax) _, err := persistTaskManualBillingDecision(task, task.Status, task.UpdatedAt, missingDecision) require.NoError(t, err) @@ -796,11 +1074,11 @@ func TestUpdateVideoSingleTaskRecoversAppliedH3FundingWithPendingEffect(t *testi InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: usage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax) require.NotNil(t, decision.Settlement) task.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(usage) + task.PrivateData.BillingContext.TaskUsageEnvelope = types.CloneTaskUsageEnvelope(decision.UsageEnvelope) won, err := task.UpdateWithStatusAndSettlementIntent(task.Status, task.UpdatedAt, *decision.Settlement) require.NoError(t, err) require.True(t, won) @@ -863,9 +1141,8 @@ func TestH3ZeroFinalSettlementRefundsAndProjectsReceiptExactlyOnce(t *testing.T) OutputDurationMs: h3UsageInt64(5_000), InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: usage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax) require.NotNil(t, decision.Settlement) require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) @@ -905,9 +1182,8 @@ func TestH3SettlementRefundsWalletWhenTokenWasDeleted(t *testing.T) { InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: usage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax) require.NotNil(t, decision.Settlement) require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) @@ -938,9 +1214,8 @@ func TestH3SettlementRefundsUnlimitedTokenExactlyOnce(t *testing.T) { InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: usage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax) require.NotNil(t, decision.Settlement) require.True(t, ApplyTaskBillingSettlement(context.Background(), task, decision.Settlement)) @@ -979,9 +1254,8 @@ func TestH3SubscriptionZeroDeltaFinalizesWithoutTouchingSubscriptionPeriod(t *te InputImageCount: h3UsageInt64(0), Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, } - decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ - Status: string(model.TaskStatusSuccess), Usage: usage, - }, constant.ChannelTypeMiniMax) + decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, + serviceH3TaskInfo(t, string(model.TaskStatusSuccess), usage), constant.ChannelTypeMiniMax) require.NotNil(t, decision.Settlement) require.Zero(t, decision.Settlement.FundingDelta) diff --git a/service/task_polling.go b/service/task_polling.go index 2ae0edd3..09e64266 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -16,6 +16,7 @@ import ( "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" "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/types" @@ -41,6 +42,14 @@ type taskUsageProvider interface { ExtractTaskUsage(responseBody []byte) (*types.TaskUsage, error) } +type taskUsageFactProvider interface { + UsageContract() types.TaskUsageContract + // UsageProducerKind is host-owned provenance. The polling boundary must + // validate against this trusted value instead of the envelope's self-report. + UsageProducerKind() string + ProduceUsage(ctx types.TaskUsageContext) (*types.TaskUsageEnvelope, error) +} + // GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。 // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor @@ -645,6 +654,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } if terminalDecision.UsesPlan && task.PrivateData.BillingContext != nil { task.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(terminalDecision.Usage) + task.PrivateData.BillingContext.TaskUsageEnvelope = types.CloneTaskUsageEnvelope(terminalDecision.UsageEnvelope) persistPendingTaskTerminalEvidence(task, taskResult, time.Now().Unix()) won, intentErr := task.UpdateWithStatusAndSettlementIntent(snap.Status, expectedUpdatedAt, *terminalDecision.Settlement) if intentErr != nil { @@ -851,6 +861,38 @@ func applyTaskUsageFacts(adaptor TaskPollingAdaptor, responseBody []byte, taskRe if adaptor == nil || taskResult == nil { return nil } + if provider, ok := adaptor.(taskUsageFactProvider); ok { + contract := provider.UsageContract() + envelope := taskResult.UsageEnvelope + if envelope == nil { + var err error + envelope, err = provider.ProduceUsage(types.TaskUsageContext{ + Stage: types.TaskUsageSourceProviderResponse, Payload: responseBody, + }) + if err != nil { + return err + } + } + if err := taskusage.ValidateEnvelope(contract, envelope, provider.UsageProducerKind()); err != nil { + return err + } + if envelope.Stage != types.TaskUsageSourceProviderResponse { + return fmt.Errorf("task usage envelope stage %q is not supported for polling", envelope.Stage) + } + // Doubao's legacy token fields feed the historical token-ratio + // settlement path. Partial provider evidence must not become a billing + // input, while other legacy providers retain their documented partial + // compatibility behavior. + if contract.SourceID != taskusage.SourceIDDoubaoVideo || + envelope.Completeness == types.TaskUsageCompletenessComplete { + if err := applyLegacyTaskUsageFields(taskResult, envelope); err != nil { + return err + } + } + taskResult.UsageEnvelope = types.CloneTaskUsageEnvelope(envelope) + taskResult.Usage = types.CloneTaskUsage(envelope.Usage) + return nil + } provider, ok := adaptor.(taskUsageProvider) if !ok { return nil @@ -865,6 +907,46 @@ func applyTaskUsageFacts(adaptor TaskPollingAdaptor, responseBody []byte, taskRe return nil } +// applyLegacyTaskUsageFields keeps the historical token fields synchronized +// with validated provider usage. Only complete or partial envelopes are +// eligible; invalid and missing usage must never become billing inputs. +func applyLegacyTaskUsageFields(taskResult *relaycommon.TaskInfo, envelope *types.TaskUsageEnvelope) error { + if taskResult == nil || envelope == nil || envelope.Usage == nil { + return nil + } + if envelope.Completeness != types.TaskUsageCompletenessComplete && + envelope.Completeness != types.TaskUsageCompletenessPartial { + return nil + } + usage := envelope.Usage + if usage.CompletionTokens != nil { + value := int(*usage.CompletionTokens) + if int64(value) != *usage.CompletionTokens { + return fmt.Errorf("completion token usage exceeds platform integer range") + } + taskResult.CompletionTokens = value + } + if usage.TotalTokens != nil { + value := int(*usage.TotalTokens) + if int64(value) != *usage.TotalTokens { + return fmt.Errorf("total token usage exceeds platform integer range") + } + taskResult.TotalTokens = value + } + if usage.CompletionTokens != nil && usage.TotalTokens != nil { + promptTokens := *usage.TotalTokens - *usage.CompletionTokens + if promptTokens < 0 { + return fmt.Errorf("total token usage cannot be less than completion token usage") + } + value := int(promptTokens) + if int64(value) != promptTokens { + return fmt.Errorf("prompt token usage exceeds platform integer range") + } + taskResult.PromptTokens = value + } + return nil +} + func recoverManualTaskBillingSettlement( ctx context.Context, task *model.Task, @@ -894,6 +976,7 @@ func recoverManualTaskBillingSettlement( billingContext := *task.PrivateData.BillingContext candidate.PrivateData.BillingContext = &billingContext candidate.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(decision.Usage) + candidate.PrivateData.BillingContext.TaskUsageEnvelope = types.CloneTaskUsageEnvelope(decision.UsageEnvelope) } persistPendingTaskTerminalEvidence(&candidate, providerResult, time.Now().Unix()) won, err := candidate.UpdateWithStatusAndPendingTerminalEvidence(fromStatus, expectedUpdatedAt) @@ -931,6 +1014,7 @@ func persistTaskManualBillingDecision(task *model.Task, fromStatus model.TaskSta } task.Status = fromStatus task.PrivateData.BillingContext.TaskUsage = types.CloneTaskUsage(decision.Usage) + task.PrivateData.BillingContext.TaskUsageEnvelope = types.CloneTaskUsageEnvelope(decision.UsageEnvelope) task.UpdatedAt = time.Now().Unix() return task.UpdateWithStatusAndManualSettlement(fromStatus, expectedUpdatedAt, *decision.Settlement, decision.ManualReason) } diff --git a/service/task_polling_test.go b/service/task_polling_test.go index e64831b3..7adce609 100644 --- a/service/task_polling_test.go +++ b/service/task_polling_test.go @@ -6,6 +6,9 @@ import ( "errors" "io" "net/http" + "os" + "path/filepath" + "runtime" "strings" "testing" "time" @@ -14,6 +17,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/model" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" "github.com/MAX-API-Next/MAX-API/types" "github.com/stretchr/testify/assert" @@ -47,7 +51,20 @@ type usagePollingResponseAdaptor struct { usageErr error } -type configuredWrapperPollingAdaptor struct{} +type usageFactPollingResponseAdaptor struct { + contract types.TaskUsageContract + envelope *types.TaskUsageEnvelope + err error + calls int + producerKind string +} + +type configuredWrapperPollingAdaptor struct { + envelope *types.TaskUsageEnvelope + adjustedPromptTokens int + adjustedCompletionTokens int + adjustedTotalTokens int +} func (a *configuredWrapperPollingAdaptor) Init(*relaycommon.RelayInfo) {} @@ -63,7 +80,8 @@ func (a *configuredWrapperPollingAdaptor) FetchTask(string, string, map[string]a "id":"439499419230570", "object":"video.generation", "status":"completed", - "data":[{"url":"https://cdn.example.com/polled.mp4"}] + "data":[{"url":"https://cdn.example.com/polled.mp4"}], + "usage":{"prompt_tokens":8,"completion_tokens":12,"total_tokens":20} } } }`)), @@ -74,10 +92,25 @@ func (a *configuredWrapperPollingAdaptor) ParseTaskResult([]byte) (*relaycommon. return nil, errors.New("configured parser should handle wrapper response") } -func (a *configuredWrapperPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { +func (a *configuredWrapperPollingAdaptor) AdjustBillingOnComplete(_ *model.Task, taskResult *relaycommon.TaskInfo) int { + a.adjustedPromptTokens = taskResult.PromptTokens + a.adjustedCompletionTokens = taskResult.CompletionTokens + a.adjustedTotalTokens = taskResult.TotalTokens return 0 } +func (a *configuredWrapperPollingAdaptor) UsageContract() types.TaskUsageContract { + return taskusage.DoubaoVideoContract() +} + +func (a *configuredWrapperPollingAdaptor) UsageProducerKind() string { + return types.TaskUsageProducerKindGoAdapter +} + +func (a *configuredWrapperPollingAdaptor) ProduceUsage(types.TaskUsageContext) (*types.TaskUsageEnvelope, error) { + return a.envelope, nil +} + func (a *usagePollingResponseAdaptor) Init(*relaycommon.RelayInfo) {} func (a *usagePollingResponseAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { @@ -96,6 +129,36 @@ func (a *usagePollingResponseAdaptor) ExtractTaskUsage([]byte) (*types.TaskUsage return a.usage, a.usageErr } +func (a *usageFactPollingResponseAdaptor) Init(*relaycommon.RelayInfo) {} + +func (a *usageFactPollingResponseAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return nil, nil +} + +func (a *usageFactPollingResponseAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return &relaycommon.TaskInfo{Status: string(model.TaskStatusSuccess)}, nil +} + +func (a *usageFactPollingResponseAdaptor) AdjustBillingOnComplete(*model.Task, *relaycommon.TaskInfo) int { + return 0 +} + +func (a *usageFactPollingResponseAdaptor) UsageContract() types.TaskUsageContract { + return a.contract +} + +func (a *usageFactPollingResponseAdaptor) UsageProducerKind() string { + if a.producerKind == "" { + return types.TaskUsageProducerKindGoAdapter + } + return a.producerKind +} + +func (a *usageFactPollingResponseAdaptor) ProduceUsage(types.TaskUsageContext) (*types.TaskUsageEnvelope, error) { + a.calls++ + return a.envelope, a.err +} + func TestApplyTaskUsageFactsAttachesProviderEvidence(t *testing.T) { taskResult := &relaycommon.TaskInfo{} usage := &types.TaskUsage{ @@ -117,9 +180,128 @@ func TestApplyTaskUsageFactsFailsClosedOnProviderError(t *testing.T) { assert.Nil(t, taskResult.Usage) } +func TestApplyTaskUsageFactsPrefersValidatedEnvelopeAndProducesOnce(t *testing.T) { + zero := int64(0) + contract := taskusage.DoubaoVideoContract() + envelope, err := taskusage.BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &zero, TotalTokens: &zero, + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + adaptor := &usageFactPollingResponseAdaptor{contract: contract, envelope: envelope} + taskResult := &relaycommon.TaskInfo{} + + require.NoError(t, applyTaskUsageFacts(adaptor, []byte(`{"usage":{}}`), taskResult)) + require.Equal(t, 1, adaptor.calls) + require.NotNil(t, taskResult.UsageEnvelope) + require.Equal(t, types.TaskUsagePresencePresentZero, taskResult.UsageEnvelope.Presence) + require.NotNil(t, taskResult.Usage) + + require.NoError(t, applyTaskUsageFacts(adaptor, []byte(`{"usage":{}}`), taskResult)) + require.Equal(t, 1, adaptor.calls, "an already validated envelope must not be produced twice") +} + +func TestApplyTaskUsageFactsRejectsTamperedEnvelope(t *testing.T) { + zero := int64(0) + contract := taskusage.DoubaoVideoContract() + envelope, err := taskusage.BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &zero, TotalTokens: &zero, + Source: types.TaskUsageSourceProviderResponse, Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + envelope.EvidenceDigest = "tampered" + adaptor := &usageFactPollingResponseAdaptor{contract: contract, envelope: envelope} + taskResult := &relaycommon.TaskInfo{} + + err = applyTaskUsageFacts(adaptor, []byte(`{}`), taskResult) + require.Error(t, err) + require.Nil(t, taskResult.Usage) + require.Nil(t, taskResult.UsageEnvelope) +} + +func TestApplyTaskUsageFactsRejectsNonProviderResponseEnvelope(t *testing.T) { + zero := int64(0) + contract := taskusage.DoubaoVideoContract() + envelope, err := taskusage.BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceRequestEstimate, &types.TaskUsage{ + CompletionTokens: &zero, TotalTokens: &zero, + Source: types.TaskUsageSourceRequestEstimate, Completeness: types.TaskUsageCompletenessComplete, + }) + require.NoError(t, err) + adaptor := &usageFactPollingResponseAdaptor{contract: contract, envelope: envelope} + taskResult := &relaycommon.TaskInfo{} + + err = applyTaskUsageFacts(adaptor, []byte(`{}`), taskResult) + require.EqualError(t, err, `task usage envelope stage "request_estimate" is not supported for polling`) + require.Nil(t, taskResult.Usage) + require.Nil(t, taskResult.UsageEnvelope) +} + +func TestApplyTaskUsageFactsDoesNotCopyPartialDoubaoUsageToLegacyFields(t *testing.T) { + completion := int64(12) + contract := taskusage.DoubaoVideoContract() + envelope, err := taskusage.BuildEnvelope(types.TaskUsageProducerKindGoAdapter, contract, types.TaskUsageSourceProviderResponse, &types.TaskUsage{ + CompletionTokens: &completion, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessPartial, + }) + require.NoError(t, err) + taskResult := &relaycommon.TaskInfo{} + adaptor := &usageFactPollingResponseAdaptor{contract: contract, envelope: envelope} + + require.NoError(t, applyTaskUsageFacts(adaptor, []byte(`{}`), taskResult)) + assert.Zero(t, taskResult.CompletionTokens) + assert.Zero(t, taskResult.TotalTokens) + require.NotNil(t, taskResult.UsageEnvelope) + assert.Equal(t, types.TaskUsageCompletenessPartial, taskResult.UsageEnvelope.Completeness) +} + +func TestApplyTaskUsageFactsAcceptsTrustedTaskPluginEnvelope(t *testing.T) { + _, sourceFile, _, ok := runtime.Caller(0) + require.True(t, ok) + data, err := os.ReadFile(filepath.Join(filepath.Dir(sourceFile), "..", "pkg", "taskusage", "testdata", "task-plugin-minimax-h3-v1.json")) + require.NoError(t, err) + var fixture struct { + ProducerKind string `json:"producer_kind"` + SourceID string `json:"source_id"` + SchemaVersion int `json:"schema_version"` + Stage string `json:"stage"` + Usage *types.TaskUsage `json:"usage"` + } + require.NoError(t, common.Unmarshal(data, &fixture)) + require.Equal(t, types.TaskUsageProducerKindTaskPlugin, fixture.ProducerKind) + contract := taskusage.MiniMaxH3Contract() + require.Equal(t, contract.SourceID, fixture.SourceID) + require.Equal(t, contract.SchemaVersion, fixture.SchemaVersion) + envelope, err := taskusage.BuildEnvelope(fixture.ProducerKind, contract, fixture.Stage, fixture.Usage) + require.NoError(t, err) + taskResult := &relaycommon.TaskInfo{} + adaptor := &usageFactPollingResponseAdaptor{ + contract: contract, envelope: envelope, producerKind: fixture.ProducerKind, + } + + require.NoError(t, applyTaskUsageFacts(adaptor, []byte(`{}`), taskResult)) + require.NotNil(t, taskResult.UsageEnvelope) + require.Equal(t, fixture.ProducerKind, taskResult.UsageEnvelope.ProducerKind) + require.EqualValues(t, 5_000, *taskResult.Usage.OutputDurationMs) +} + func TestUpdateVideoSingleTaskUsesConfiguredParserForWrappedProviderResult(t *testing.T) { truncate(t) baseURL := "https://upstream.example.com" + completionTokens := int64(12) + totalTokens := int64(20) + envelope, err := taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.DoubaoVideoContract(), + types.TaskUsageSourceProviderResponse, + &types.TaskUsage{ + CompletionTokens: &completionTokens, + TotalTokens: &totalTokens, + Source: types.TaskUsageSourceProviderResponse, + Completeness: types.TaskUsageCompletenessComplete, + }, + ) + require.NoError(t, err) task := &model.Task{ TaskID: "task_wrapped_polling", Status: model.TaskStatusInProgress, @@ -138,13 +320,17 @@ func TestUpdateVideoSingleTaskUsesConfiguredParserForWrappedProviderResult(t *te task.ChannelId = channel.Id require.NoError(t, model.DB.Create(channel).Error) - err := updateVideoSingleTask(context.Background(), &configuredWrapperPollingAdaptor{}, channel, task.TaskID, map[string]*model.Task{task.TaskID: task}) + adaptor := &configuredWrapperPollingAdaptor{envelope: envelope} + err = updateVideoSingleTask(context.Background(), adaptor, channel, task.TaskID, map[string]*model.Task{task.TaskID: task}) require.NoError(t, err) var reloaded model.Task require.NoError(t, model.DB.First(&reloaded, task.ID).Error) assert.EqualValues(t, model.TaskStatusSuccess, reloaded.Status) assert.Equal(t, "https://cdn.example.com/polled.mp4", reloaded.PrivateData.ResultURL) + assert.Equal(t, 8, adaptor.adjustedPromptTokens) + assert.Equal(t, 12, adaptor.adjustedCompletionTokens) + assert.Equal(t, 20, adaptor.adjustedTotalTokens) } func TestAppliedTaskRecoveryPreservesSnapshotOnCASLoss(t *testing.T) { diff --git a/setting/task_billing_setting/h3.go b/setting/task_billing_setting/h3.go index 96052bde..d4d73175 100644 --- a/setting/task_billing_setting/h3.go +++ b/setting/task_billing_setting/h3.go @@ -8,6 +8,7 @@ import ( "github.com/MAX-API-Next/MAX-API/common" "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" "github.com/MAX-API-Next/MAX-API/types" "github.com/shopspring/decimal" ) @@ -351,6 +352,11 @@ func buildH3BillingPlan(profile H3BillingConfig, input H3BillingInput, groupRati 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") } + usageContract := taskusage.MiniMaxH3Contract() + usageContractDigest, err := taskusage.ContractDigest(usageContract) + if err != nil { + return nil, fmt.Errorf("build H3 usage contract digest: %w", err) + } videoReserve := int64(0) if input.InputVideoCount > 0 { @@ -366,6 +372,10 @@ func buildH3BillingPlan(profile H3BillingConfig, input H3BillingInput, groupRati GroupRatio: groupRatio, QuotaPerUnit: common.QuotaPerUnit, Resolution: resolution, + UsageProducerKind: types.TaskUsageProducerKindGoAdapter, + UsageSourceID: usageContract.SourceID, + UsageSchemaVersion: usageContract.SchemaVersion, + UsageContractDigest: usageContractDigest, RequestedOutputDurationSeconds: input.OutputDurationSeconds, InputVideoCount: input.InputVideoCount, InputAudioCount: input.InputAudioCount, @@ -584,6 +594,34 @@ func validateH3BillingPlan(plan *types.TaskBillingPlan) error { if strings.TrimSpace(plan.ConfigHash) == "" { return fmt.Errorf("H3 billing plan config hash is invalid") } + usageIdentityFields := 0 + if plan.UsageProducerKind != "" { + usageIdentityFields++ + } + if plan.UsageSourceID != "" { + usageIdentityFields++ + } + if plan.UsageSchemaVersion != 0 { + usageIdentityFields++ + } + if plan.UsageContractDigest != "" { + usageIdentityFields++ + } + if plan.Source == H3BillingSource && usageIdentityFields != 4 { + return fmt.Errorf("H3 billing plan usage contract identity is required") + } + if usageIdentityFields != 0 { + if usageIdentityFields != 4 { + return fmt.Errorf("H3 billing plan usage contract identity is incomplete") + } + if plan.UsageProducerKind != types.TaskUsageProducerKindGoAdapter || + plan.UsageSourceID != taskusage.SourceIDMiniMax { + return fmt.Errorf("H3 billing plan usage contract identity is invalid") + } + if _, err := taskusage.ResolveContract(plan.UsageSourceID, plan.UsageSchemaVersion, plan.UsageContractDigest); err != nil { + return fmt.Errorf("H3 billing plan usage contract identity is invalid: %w", err) + } + } if plan.GroupRatio < 0 || math.IsNaN(plan.GroupRatio) || math.IsInf(plan.GroupRatio, 0) { return fmt.Errorf("H3 billing plan group ratio is invalid") } diff --git a/setting/task_billing_setting/h3_test.go b/setting/task_billing_setting/h3_test.go index 861d6517..4bdc4b68 100644 --- a/setting/task_billing_setting/h3_test.go +++ b/setting/task_billing_setting/h3_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/pkg/taskusage" "github.com/MAX-API-Next/MAX-API/setting/config" "github.com/MAX-API-Next/MAX-API/types" "github.com/stretchr/testify/require" @@ -43,6 +44,51 @@ func TestBuildH3BillingPlanUsesOneAggregateInputVideoCap(t *testing.T) { // 5 output seconds + one aggregate 15-second input-video cap. require.EqualValues(t, 20, reserve.OutputSeconds+reserve.InputVideoSeconds) require.EqualValues(t, 1600, reserve.Quota) + require.Equal(t, types.TaskUsageProducerKindGoAdapter, plan.UsageProducerKind) + require.Equal(t, taskusage.MiniMaxH3Contract().SourceID, plan.UsageSourceID) + require.Equal(t, taskusage.MiniMaxH3Contract().SchemaVersion, plan.UsageSchemaVersion) + require.NotEmpty(t, plan.UsageContractDigest) +} + +func TestValidateH3BillingPlanSnapshotRejectsUsageContractTampering(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + + plan.UsageSourceID = "client-controlled-source" + require.ErrorContains(t, ValidateH3BillingPlanSnapshot(plan), "usage contract identity") +} + +func TestValidateH3BillingPlanSnapshotRequiresUsageIdentityForCurrentH3Plans(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + plan.Source = H3BillingSource + plan.UsageProducerKind = "" + plan.UsageSourceID = "" + plan.UsageSchemaVersion = 0 + plan.UsageContractDigest = "" + + require.ErrorContains(t, ValidateH3BillingPlanSnapshot(plan), "usage contract identity is required") +} + +func TestValidateH3BillingPlanSnapshotKeepsLegacyIdentityCompatibility(t *testing.T) { + withQuotaPerUnit(t, 1000) + plan, err := BuildH3BillingPlan(H3BillingInput{ + Resolution: "768P", OutputDurationSeconds: 5, + }, 1) + require.NoError(t, err) + plan.Source = LegacyH3BillingSource + plan.UsageProducerKind = "" + plan.UsageSourceID = "" + plan.UsageSchemaVersion = 0 + plan.UsageContractDigest = "" + + require.NoError(t, ValidateH3BillingPlanSnapshot(plan)) } func TestBuildH3BillingPlanReadsMinimaxRuleFromUnifiedRateCards(t *testing.T) { diff --git a/types/task_billing_plan.go b/types/task_billing_plan.go index 3126c4d0..324e32c0 100644 --- a/types/task_billing_plan.go +++ b/types/task_billing_plan.go @@ -21,6 +21,11 @@ type TaskBillingPlan struct { QuotaPerUnit float64 `json:"quota_per_unit"` Resolution string `json:"resolution"` + UsageProducerKind string `json:"usage_producer_kind,omitempty"` + UsageSourceID string `json:"usage_source_id,omitempty"` + UsageSchemaVersion int `json:"usage_schema_version,omitempty"` + UsageContractDigest string `json:"usage_contract_digest,omitempty"` + RequestedOutputDurationSeconds int64 `json:"requested_output_duration_seconds"` InputVideoCount int64 `json:"input_video_count"` InputAudioCount int64 `json:"input_audio_count"` diff --git a/types/task_usage.go b/types/task_usage.go index c77637a0..653b30ee 100644 --- a/types/task_usage.go +++ b/types/task_usage.go @@ -15,6 +15,37 @@ const ( TaskUsageCompletenessAmbiguous = "ambiguous" ) +const ( + TaskUsageProducerKindGoAdapter = "go_adapter" + TaskUsageProducerKindTaskPlugin = "task_plugin" +) + +const ( + TaskUsagePresenceNotPresent = "not_present" + TaskUsagePresencePresentZero = "present_zero" + TaskUsagePresencePresentValid = "present_valid" + TaskUsagePresencePartial = "partial" + TaskUsagePresenceInvalid = "invalid" + TaskUsagePresenceAmbiguous = "ambiguous" +) + +const ( + TaskUsageFieldOutputDurationMs = "output_duration_ms" + TaskUsageFieldInputVideoDurationMs = "input_video_duration_ms" + TaskUsageFieldInputAudioDurationMs = "input_audio_duration_ms" + TaskUsageFieldInputImageCount = "input_image_count" + TaskUsageFieldInputVideoCount = "input_video_count" + TaskUsageFieldInputAudioCount = "input_audio_count" + TaskUsageFieldCompletionTokens = "completion_tokens" + TaskUsageFieldTotalTokens = "total_tokens" +) + +const ( + TaskUsageUnitMillisecond = "millisecond" + TaskUsageUnitCount = "count" + TaskUsageUnitToken = "token" +) + // 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. @@ -25,10 +56,55 @@ type TaskUsage struct { InputImageCount *int64 `json:"input_image_count,omitempty"` InputVideoCount *int64 `json:"input_video_count,omitempty"` InputAudioCount *int64 `json:"input_audio_count,omitempty"` + CompletionTokens *int64 `json:"completion_tokens,omitempty"` + TotalTokens *int64 `json:"total_tokens,omitempty"` Source string `json:"source,omitempty"` Completeness string `json:"completeness,omitempty"` } +// TaskUsageFieldContract declares one bounded canonical fact. It is data-only: +// it cannot parse provider payloads, select prices, or mutate task/accounting +// state. +type TaskUsageFieldContract struct { + Key string `json:"key"` + Unit string `json:"unit"` + RequiredForTerminal bool `json:"required_for_terminal,omitempty"` + MinValue *int64 `json:"min_value,omitempty"` + MaxValue *int64 `json:"max_value,omitempty"` +} + +// TaskUsageContract identifies the finite canonical facts produced for one +// provider/source schema. SourceID and SchemaVersion are host-owned identity, +// never values accepted from a client request. +type TaskUsageContract struct { + SourceID string `json:"source_id"` + SchemaVersion int `json:"schema_version"` + Fields []TaskUsageFieldContract `json:"fields"` +} + +// TaskUsageContext is deliberately restricted to the fact production stage +// and payload. It carries no URL, credential, price, task mutation, or funding +// authority. +type TaskUsageContext struct { + Stage string + Payload []byte +} + +// TaskUsageEnvelope binds canonical facts to their producer and versioned +// contract. Digests cover only canonical contract/fact material and never the +// raw provider payload. +type TaskUsageEnvelope struct { + ProducerKind string `json:"producer_kind"` + SourceID string `json:"source_id"` + SchemaVersion int `json:"schema_version"` + ContractDigest string `json:"contract_digest"` + Stage string `json:"stage"` + Presence string `json:"presence"` + Completeness string `json:"completeness"` + EvidenceDigest string `json:"evidence_digest"` + Usage *TaskUsage `json:"usage,omitempty"` +} + func CloneTaskUsage(usage *TaskUsage) *TaskUsage { if usage == nil { return nil @@ -40,6 +116,17 @@ func CloneTaskUsage(usage *TaskUsage) *TaskUsage { cloned.InputImageCount = cloneInt64Pointer(usage.InputImageCount) cloned.InputVideoCount = cloneInt64Pointer(usage.InputVideoCount) cloned.InputAudioCount = cloneInt64Pointer(usage.InputAudioCount) + cloned.CompletionTokens = cloneInt64Pointer(usage.CompletionTokens) + cloned.TotalTokens = cloneInt64Pointer(usage.TotalTokens) + return &cloned +} + +func CloneTaskUsageEnvelope(envelope *TaskUsageEnvelope) *TaskUsageEnvelope { + if envelope == nil { + return nil + } + cloned := *envelope + cloned.Usage = CloneTaskUsage(envelope.Usage) return &cloned } diff --git a/web/default/scripts/sync-i18n.mjs b/web/default/scripts/sync-i18n.mjs index 61afaec3..c9773c8f 100644 --- a/web/default/scripts/sync-i18n.mjs +++ b/web/default/scripts/sync-i18n.mjs @@ -138,6 +138,30 @@ function countLeafKeys(obj) { return count } +function validateEnglishBase(parsedByLocale, baseLocale) { + if (baseLocale !== 'en') return + const englishLeafCount = countLeafKeys(parsedByLocale.en?.translation ?? {}) + if (englishLeafCount > 0) return + + const localesWithKeys = Object.entries(parsedByLocale) + .filter(([locale]) => locale !== 'en') + .filter(([, json]) => countLeafKeys(json?.translation ?? {}) > 0) + .map(([locale]) => locale) + if (localesWithKeys.length > 0) { + throw new Error( + `English locale has no translation keys while other locales contain keys: ${localesWithKeys.join(', ')}` + ) + } +} + +function isPluralVariantOfBaseKey(key, base) { + const match = key.match(/^(.*)_(zero|one|two|few|many|other)$/) + return ( + match !== null && + Object.prototype.hasOwnProperty.call(base, match[1]) + ) +} + function reorderLikeBase(base, target, fill, extras, missing, currentPath = []) { // If base is an object, we keep base's key order and recurse. if (isPlainObject(base)) { @@ -157,8 +181,16 @@ function reorderLikeBase(base, target, fill, extras, missing, currentPath = []) for (const key of Object.keys(t)) { if (!Object.prototype.hasOwnProperty.call(base, key)) { - const nextPath = [...currentPath, key].join('.') - extras[nextPath] = t[key] + if (isPluralVariantOfBaseKey(key, base)) { + // Plural categories can differ by locale (for example, Russian + // needs _few and _many while English does not). Keep those runtime + // variants beside their existing base key instead of classifying + // them as unrelated extras. + out[key] = t[key] + } else { + const nextPath = [...currentPath, key].join('.') + extras[nextPath] = t[key] + } } } @@ -220,7 +252,9 @@ async function main() { .map((e) => e.name) .sort((a, b) => a.localeCompare(b)) - // Auto-pick base locale as the one with the most leaf keys under translation (most "rich"). + // English is the source of truth for runtime keys. A translated locale can + // contain plural variants or provider-specific additions and must not become + // the schema base merely because it has more leaf keys. const parsedByLocale = {} for (const filename of localeFiles) { const locale = filename.replace(/\.json$/i, '') @@ -228,16 +262,21 @@ async function main() { parsedByLocale[locale] = JSON.parse(raw) } - const baseLocale = Object.keys(parsedByLocale) - .map((locale) => { - const json = parsedByLocale[locale] - const trans = json?.translation ?? {} - return { locale, score: countLeafKeys(trans) } - }) - .sort((a, b) => b.score - a.score || a.locale.localeCompare(b.locale))[0]?.locale + const baseLocale = parsedByLocale.en + ? 'en' + : Object.keys(parsedByLocale) + .map((locale) => { + const json = parsedByLocale[locale] + const trans = json?.translation ?? {} + return { locale, score: countLeafKeys(trans) } + }) + .sort((a, b) => b.score - a.score || a.locale.localeCompare(b.locale))[0]?.locale if (!baseLocale) throw new Error('No locale files found.') + // Never rewrite translated locale files from an empty English schema. + validateEnglishBase(parsedByLocale, baseLocale) + const baseFile = `${baseLocale}.json` const baseJson = parsedByLocale[baseLocale] diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index b3194ed3..30e5f65e 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -16,16 +16,31 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useMemo, useState, type ReactElement } from 'react' +import { act, useMemo, useState, type ReactElement } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import en from '@/i18n/locales/en.json' +import fr from '@/i18n/locales/fr.json' +import ja from '@/i18n/locales/ja.json' +import ru from '@/i18n/locales/ru.json' +import vi from '@/i18n/locales/vi.json' +import zh from '@/i18n/locales/zh.json' import { createReactTestEnvironment } from '@/test/react' -import { waitFor, within } from '@testing-library/react' +import { fireEvent, waitFor, within } from '@testing-library/react' +import i18next from 'i18next' import assert from 'node:assert/strict' import { after, describe, test } from 'node:test' import { useAuthStore } from '@/stores/auth-store' +import { useSystemConfigStore } from '@/stores/system-config-store' import { api } from '@/lib/api' -import { completeManualTaskBillingSettlement } from './api' -import type { BillingSettlementReconciliationData } from './types' +import { + completeManualTaskBillingSettlement, + completeManualTaskBillingSettlements, + completeManualTaskBillingSettlementsZero, +} from './api' +import type { + BillingSettlementReconciliationData, + BillingSettlementReconciliationItem, +} from './types' const LOAD_ERROR_KEY = 'We could not load active alerts.' const testEnv = createReactTestEnvironment({ @@ -93,6 +108,7 @@ function ManualSettlementEvidenceHarness(): ReactElement { task_quota: 100, task_quota_target: 100, requires_manual_completion: true, + zero_quota_eligible: false, funding_delta: 0, applied_funding_delta: 0, token_delta: 0, @@ -123,7 +139,7 @@ function ManualSettlementEvidenceHarness(): ReactElement { undefined} @@ -133,6 +149,43 @@ function ManualSettlementEvidenceHarness(): ReactElement { } describe('SmartOps active alerts', () => { + test('keeps zero-quota confirmation wording pluralized in every locale', async (): Promise => { + const key = + 'This will settle {{displayCount}} selected MiniMax-H3 task(s) with an explicit final quota of 0 and refund the unused reservation. Continue only after verifying the provider result.' + const locales = { en, fr, ja, ru, vi, zh } + for (const [lng, resource] of Object.entries(locales)) { + const instance = i18next.createInstance() + await instance.init({ + lng, + fallbackLng: false, + resources: { [lng]: resource }, + interpolation: { escapeValue: false }, + }) + const zeroMessage = instance.t(key, { + count: 0, + displayCount: '7', + }) + const distinctMessage = instance.t(key, { + count: 3, + displayCount: '3', + }) + assert.notEqual(zeroMessage, distinctMessage) + assert.ok(zeroMessage.includes('7')) + assert.ok(distinctMessage.includes('3')) + for (const count of [1, 2]) { + const message = instance.t(key, { + count, + displayCount: String(count), + }) + assert.ok(message.includes(String(count))) + assert.doesNotMatch(message, /task\(s\)/) + if (lng === 'ru' && count === 2) { + assert.match(message, /выбранных задач MiniMax-H3/) + } + } + } + }) + test('posts the exact manual task settlement contract', async (): Promise => { const originalPost = api.post const writes: Array<{ url: string; data: unknown; config: unknown }> = [] @@ -149,7 +202,6 @@ describe('SmartOps active alerts', () => { const response = await completeManualTaskBillingSettlement(93, { revision: 2, actual_quota: 0, - note: 'Verified provider evidence and exact usage.', }) assert.equal(response.success, true) @@ -159,7 +211,6 @@ describe('SmartOps active alerts', () => { data: { revision: 2, actual_quota: 0, - note: 'Verified provider evidence and exact usage.', }, config: { skipBusinessError: true, skipErrorHandler: true }, }, @@ -169,6 +220,131 @@ describe('SmartOps active alerts', () => { } }) + test('passes a non-zero exact quota to the manual settlement endpoint', async (): Promise => { + const originalPost = api.post + const writes: Array<{ url: string; data: unknown }> = [] + api.post = (async (url: string, data: unknown): Promise => { + writes.push({ url: String(url), data }) + return { data: { success: true, data: { actual_quota: 40 } } } + }) as typeof api.post + + try { + const response = await completeManualTaskBillingSettlement(93, { + revision: 2, + actual_quota: 40, + }) + + assert.equal(response.success, true) + assert.deepEqual(writes, [ + { + url: '/api/smart-ops/billing-settlements/93/complete-task', + data: { + revision: 2, + actual_quota: 40, + }, + }, + ]) + } finally { + api.post = originalPost + } + }) + + test('passes per-task exact quotas, including an explicit zero, to the batch endpoint', async (): Promise => { + const originalPost = api.post + const writes: Array<{ + url: string + data: unknown + config: unknown + }> = [] + api.post = (async ( + url: string, + data: unknown, + config: unknown + ): Promise => { + writes.push({ url: String(url), data, config }) + return { + data: { + success: true, + data: { + completed_count: 2, + failed_count: 0, + settlement_ids: [93, 94], + failed: [], + }, + }, + } + }) as typeof api.post + + try { + const response = await completeManualTaskBillingSettlements({ + items: [ + { id: 93, revision: 2, actual_quota: 0 }, + { id: 94, revision: 7, actual_quota: 40 }, + ], + }) + + assert.equal(response.success, true) + assert.deepEqual(writes, [ + { + url: '/api/smart-ops/billing-settlements/complete-tasks', + data: { + items: [ + { id: 93, revision: 2, actual_quota: 0 }, + { id: 94, revision: 7, actual_quota: 40 }, + ], + }, + config: { skipBusinessError: true, skipErrorHandler: true }, + }, + ]) + } finally { + api.post = originalPost + } + }) + + test('rejects malformed batch settlement responses at the API boundary', async (): Promise => { + const originalPost = api.post + api.post = (async (): Promise => ({ + data: { + success: true, + data: { + completed_count: 1, + failed_count: 0, + settlement_ids: 'not-an-array', + failed: [], + }, + }, + })) as typeof api.post + + try { + await assert.rejects( + completeManualTaskBillingSettlements({ + items: [{ id: 93, revision: 2, actual_quota: 40 }], + }) + ) + await assert.rejects( + completeManualTaskBillingSettlementsZero({ + items: [{ id: 93, revision: 2 }], + }) + ) + + api.post = (async (): Promise => ({ + data: { success: true }, + })) as typeof api.post + await assert.rejects( + completeManualTaskBillingSettlements({ + items: [{ id: 93, revision: 2, actual_quota: 40 }], + }) + ) + await assert.rejects( + completeManualTaskBillingSettlementsZero({ + items: [{ id: 93, revision: 2 }], + }) + ) + } finally { + api.post = originalPost + } + }) + test('polls the administrator alert endpoint and renders active host pressure', async (): Promise => { const originalGet = api.get const urls: string[] = [] @@ -489,7 +665,7 @@ describe('SmartOps active alerts', () => { await waitFor(() => { assert.ok( within(view.container).getByRole('button', { - name: 'Complete billing', + name: 'Enter exact quota', }) ) }) @@ -499,7 +675,7 @@ describe('SmartOps active alerts', () => { assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') await view.click( within(view.container).getByRole('button', { - name: 'Complete billing', + name: 'Enter exact quota', }) ) @@ -544,6 +720,101 @@ describe('SmartOps active alerts', () => { } }) + test('blocks batch exact settlement after reconciliation refresh fails', async (): Promise => { + const originalUser = useAuthStore.getState().auth.user + const originalSystemConfigLoading = useSystemConfigStore.getState().loading + useAuthStore.getState().auth.setUser({ + id: 1, + username: 'root', + role: 100, + }) + useSystemConfigStore.getState().setLoading(false) + const htmlElementPrototype = window.HTMLElement + .prototype as typeof window.HTMLElement.prototype & { + attachEvent?: (name: string, listener: EventListener) => void + detachEvent?: (name: string, listener: EventListener) => void + } + htmlElementPrototype.attachEvent = function (name, listener) { + this.addEventListener(name.replace(/^on/, ''), listener) + } + htmlElementPrototype.detachEvent = function (name, listener) { + this.removeEventListener(name.replace(/^on/, ''), listener) + } + const queryClient = createQueryClient() + const view = await testEnv.render( + + + + ) + + try { + await view.click( + await waitFor(() => + within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 701', + }) + ) + ) + await view.click( + within(view.container).getByRole('button', { + name: 'Enter exact quotas (1)', + }) + ) + const input = await waitFor(() => { + const element = document.getElementById( + 'manual-task-actual-quota-701' + ) as HTMLInputElement | null + assert.ok(element) + return element + }) + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + assert.ok(setter) + await act(async () => { + setter.call(input, '40') + fireEvent.input(input) + fireEvent.change(input) + }) + const submitButton = await waitFor(() => { + const button = within(document.body).getByRole('button', { + name: 'Apply exact settlements', + }) + assert.equal((button as HTMLButtonElement).disabled, false) + return button + }) + + const causeErrorButton = view.container.querySelector( + 'button' + ) as HTMLButtonElement + assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') + await view.click(causeErrorButton) + await waitFor(() => { + assert.ok( + (view.container.textContent ?? '').includes( + 'We could not load billing reconciliation details.' + ) + ) + assert.ok( + ( + document.body.querySelector('[role="dialog"]')?.textContent ?? '' + ).includes( + 'This reconciliation record changed while the dialog was open.' + ) + ) + }) + assert.equal((submitButton as HTMLButtonElement).disabled, true) + } finally { + delete htmlElementPrototype.attachEvent + delete htmlElementPrototype.detachEvent + queryClient.clear() + await view.unmount() + useAuthStore.getState().auth.setUser(originalUser) + useSystemConfigStore.getState().setLoading(originalSystemConfigLoading) + } + }) + test('batch closes selected active reconciliation alerts without requesting notes', async (): Promise => { const originalUser = useAuthStore.getState().auth.user useAuthStore.getState().auth.setUser({ @@ -744,6 +1015,8 @@ describe('SmartOps active alerts', () => { role: 100, }) const originalGet = api.get + const originalPost = api.post + const writes: Array<{ url: string; data: unknown }> = [] const htmlElementPrototype = window.HTMLElement .prototype as typeof window.HTMLElement.prototype & { attachEvent?: (name: string, listener: EventListener) => void @@ -759,7 +1032,7 @@ describe('SmartOps active alerts', () => { } const manualRevision = 2 const manualCompletionRequired = true - let includeManualItem = true + const includeManualItem = true api.get = (async (url: string): Promise => { return { data: @@ -768,9 +1041,9 @@ describe('SmartOps active alerts', () => { success: true, data: { ...emptyReconciliationData(), - total_count: includeManualItem ? 1 : 0, - manual_count: includeManualItem ? 1 : 0, - open_alert_count: includeManualItem ? 1 : 0, + total_count: includeManualItem ? 3 : 0, + manual_count: includeManualItem ? 2 : 0, + open_alert_count: includeManualItem ? 3 : 0, items: includeManualItem ? [ { @@ -786,6 +1059,37 @@ describe('SmartOps active alerts', () => { task_quota: 100, task_quota_target: 100, requires_manual_completion: manualCompletionRequired, + zero_quota_eligible: manualCompletionRequired, + funding_delta: 0, + applied_funding_delta: 0, + token_delta: 0, + applied_token_delta: 0, + attempts: 0, + last_error: 'provider usage needs verification', + next_attempt: 0, + created_at: 1786032545, + updated_at: 1786032545, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: false, + blocks_user: false, + }, + { + id: 94, + revision: manualRevision, + operation_key: 'task:7002:finalize', + status: 'manual', + source: 'wallet', + user_id: 53, + subscription_id: 0, + token_id: 54, + task_id: 7002, + task_quota: 120, + task_quota_target: 120, + requires_manual_completion: manualCompletionRequired, + zero_quota_eligible: manualCompletionRequired, funding_delta: 0, applied_funding_delta: 0, token_delta: 0, @@ -802,6 +1106,36 @@ describe('SmartOps active alerts', () => { record_blocks_user: false, blocks_user: false, }, + { + id: 95, + revision: 1, + operation_key: 'request:billing-request-95:finalize', + status: 'pending', + source: 'wallet', + user_id: 54, + subscription_id: 0, + token_id: 0, + task_id: 0, + task_quota: 0, + task_quota_target: 0, + requires_manual_completion: false, + zero_quota_eligible: false, + funding_delta: 100, + applied_funding_delta: 0, + token_delta: 100, + applied_token_delta: 0, + attempts: 1, + last_error: 'ordinary alert needs review', + next_attempt: 0, + created_at: 1786032546, + updated_at: 1786032546, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: true, + blocks_user: true, + }, ] : [], }, @@ -809,6 +1143,43 @@ describe('SmartOps active alerts', () => { : { success: true, data: [] }, } }) as typeof api.get + api.post = (async (url: string, data: unknown): Promise => { + writes.push({ url: String(url), data }) + if (url === '/api/smart-ops/billing-settlements/reviews') { + return { data: { success: true, data: [] } } + } + if (url !== '/api/smart-ops/billing-settlements/complete-tasks-zero') { + return { + data: { + success: true, + data: { + completed_count: 2, + failed_count: 0, + settlement_ids: [93, 94], + failed: [], + }, + }, + } + } + return { + data: { + success: true, + data: { + completed_count: 1, + failed_count: 1, + settlement_ids: [93], + failed: [ + { + settlement_id: 94, + code: 'record_conflict', + message: + 'record changed or could not be applied safely; refresh and reconcile it', + }, + ], + }, + }, + } + }) as typeof api.post const queryClient = createQueryClient() const view = await testEnv.render( @@ -817,19 +1188,20 @@ describe('SmartOps active alerts', () => { ) try { - let completeButton: HTMLElement | undefined await waitFor(() => { const screen = within(view.container) - completeButton = screen.getByRole('button', { - name: 'Complete billing', - }) + assert.ok( + screen.getAllByRole('button', { + name: 'Confirm zero-quota settlement', + }).length > 0 + ) assert.equal( screen .getByRole('checkbox', { name: 'Select billing reconciliation alert 93', }) .hasAttribute('data-disabled'), - true + false ) assert.equal( screen @@ -840,65 +1212,580 @@ describe('SmartOps active alerts', () => { true ) }) - assert.ok(completeButton) - await view.click(completeButton) - - const dialog = await waitFor(() => - within(document.body).getByRole('dialog') + await view.click( + within(view.container).getAllByRole('button', { + name: 'Confirm zero-quota settlement', + })[0] ) - const dialogScreen = within(dialog) - const quotaInput = dialogScreen.getByLabelText('Exact final quota') - const noteInput = dialogScreen.getByLabelText('Audit note') - const submitButton = dialogScreen.getByRole('button', { - name: 'Apply exact settlement', + await view.click( + within(document.body).getByRole('button', { name: 'Cancel' }) + ) + assert.deepEqual(writes, []) + await view.click( + within(view.container).getAllByRole('button', { + name: 'Confirm zero-quota settlement', + })[0] + ) + await view.click( + within(document.body).getByRole('button', { + name: 'Apply zero-quota settlements', + }) + ) + await waitFor(() => { + assert.deepEqual(writes[0], { + url: '/api/smart-ops/billing-settlements/complete-tasks-zero', + data: { items: [{ id: 93, revision: manualRevision }] }, + }) }) - assert.equal(submitButton.hasAttribute('disabled'), true) - assert.equal((quotaInput as HTMLInputElement).max, '100') - assert.equal((quotaInput as HTMLInputElement).min, '0') - assert.equal((noteInput as HTMLTextAreaElement).maxLength, 1000) + const taskCheckbox = within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 93', + }) + await view.click(taskCheckbox) + await view.click( + within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 94', + }) + ) assert.ok( - (dialog.textContent ?? '').includes( - 'This workflow cannot add a charge above the original reservation.' + within(view.container).getByRole('button', { + name: 'Enter exact quotas (2)', + }) + ) + const zeroBatchButton = within(view.container).getByRole('button', { + name: 'Confirm zero-quota settlements (2)', + }) + assert.equal(zeroBatchButton.hasAttribute('disabled'), false) + await view.click(zeroBatchButton) + await view.click( + within(document.body).getByRole('button', { + name: 'Apply zero-quota settlements', + }) + ) + await waitFor(() => { + assert.deepEqual(writes[1], { + url: '/api/smart-ops/billing-settlements/complete-tasks-zero', + data: { + items: [ + { id: 93, revision: manualRevision }, + { id: 94, revision: manualRevision }, + ], + }, + }) + assert.ok( + (view.container.textContent ?? '').includes('Settlement #94:') ) + }) + assert.equal(within(document.body).queryByRole('dialog'), null) + assert.ok( + within(view.container).getAllByRole('button', { + name: 'Enter exact quota', + }).length > 0 + ) + + // The H3 zero-quota shortcut must not hide the exact settlement path. + // Open the real dialog and verify that the exact input remains available + // instead of being replaced by the zero-quota batch action. + await view.click( + within(view.container).getAllByRole('button', { + name: 'Enter exact quota', + })[0] + ) + const exactQuotaInput = document.getElementById( + 'manual-task-actual-quota' + ) as HTMLInputElement + assert.ok(exactQuotaInput) + assert.equal(exactQuotaInput.type, 'number') + assert.equal(exactQuotaInput.value, '') + assert.equal(exactQuotaInput.min, '0') + assert.equal(exactQuotaInput.max, '100') + assert.equal( + ( + within(document.body).getByRole('button', { + name: 'Apply exact settlement', + }) as HTMLButtonElement + ).disabled, + true + ) + + await view.click( + within(document.body).getByRole('button', { name: 'Cancel' }) + ) + // The exact dialog was opened from the manual-task rows above. After it + // closes, select only the ordinary alert to verify that manual task + // completion remains separate from ordinary batch review. + await view.click( + within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 93', + }) + ) + await view.click( + within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 95', + }) + ) + const mixedSelectionButton = within(view.container).getByRole('button', { + name: 'Select either task settlements or ordinary alerts, not both.', + }) as HTMLButtonElement + assert.equal(mixedSelectionButton.disabled, true) + assert.equal(writes.length, 2) + await view.click( + within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 93', + }) + ) + await view.click( + within(view.container).getByRole('button', { + name: 'Review and close selected (1)', + }) ) + await waitFor(() => { + assert.deepEqual(writes[2], { + url: '/api/smart-ops/billing-settlements/reviews', + data: { items: [{ id: 95, revision: 1 }] }, + }) + assert.ok( + (view.container.textContent ?? '').includes('Settlement #94:') + ) + }) + + // Opening the exact-settlement path must not issue another zero-quota + // request. The exact endpoint contract is covered by the API test. + assert.equal(writes.length, 3) + } finally { + api.get = originalGet + api.post = originalPost + delete htmlElementPrototype.attachEvent + delete htmlElementPrototype.detachEvent + await view.unmount() + queryClient.clear() + useAuthStore.getState().auth.setUser(originalUser) + } + }) - includeManualItem = false - await queryClient.invalidateQueries({ - queryKey: ['smart-ops', 'billing-settlement-reconciliation'], + test('submits per-task exact quotas from the selected batch dialog', async (): Promise => { + const originalUser = useAuthStore.getState().auth.user + useAuthStore.getState().auth.setUser({ + id: 1, + username: 'root', + role: 100, + }) + const originalSystemConfigLoading = useSystemConfigStore.getState().loading + useSystemConfigStore.getState().setLoading(false) + const htmlElementPrototype = window.HTMLElement + .prototype as typeof window.HTMLElement.prototype & { + attachEvent?: (name: string, listener: EventListener) => void + detachEvent?: (name: string, listener: EventListener) => void + } + htmlElementPrototype.attachEvent = function (name, listener) { + this.addEventListener(name.replace(/^on/, ''), listener) + } + htmlElementPrototype.detachEvent = function (name, listener) { + this.removeEventListener(name.replace(/^on/, ''), listener) + } + const originalGet = api.get + const originalPost = api.post + const writes: Array<{ url: string; data: unknown }> = [] + type TaskItemSeed = { + id: number + revision: number + operation_key: string + task_id: number + task_quota: number + zero_quota_eligible: boolean + } + const taskItems = ( + [ + { + id: 101, + revision: 2, + operation_key: 'task:7101:finalize', + task_id: 7101, + task_quota: 100, + zero_quota_eligible: true, + }, + { + id: 102, + revision: 3, + operation_key: 'task:7102:finalize', + task_id: 7102, + task_quota: 80, + zero_quota_eligible: true, + }, + ] satisfies TaskItemSeed[] + ).map( + (item: TaskItemSeed): BillingSettlementReconciliationItem => ({ + ...item, + status: 'manual' as const, + source: 'wallet' as const, + user_id: 53, + subscription_id: 0, + token_id: 54, + task_quota_target: item.task_quota, + requires_manual_completion: true, + funding_delta: 0, + applied_funding_delta: 0, + token_delta: 0, + applied_token_delta: 0, + attempts: 0, + last_error: 'provider usage needs verification', + next_attempt: 0, + created_at: 1786032545, + updated_at: 1786032545, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: false, + blocks_user: false, }) + ) + api.get = (async (url: string): Promise => ({ + data: + url === '/api/smart-ops/billing-settlements' + ? { + success: true, + data: { + ...emptyReconciliationData(), + total_count: taskItems.length, + manual_count: taskItems.length, + open_alert_count: taskItems.length, + items: taskItems, + }, + } + : { success: true, data: [] }, + })) as typeof api.get + api.post = (async (url: string, data: unknown): Promise => { + writes.push({ url: String(url), data }) + return { + data: { + success: true, + data: { + completed_count: 2, + failed_count: 0, + settlement_ids: [101, 102], + failed: [], + }, + }, + } + }) as typeof api.post - const staleDialog = await waitFor(() => { - const currentDialog = within(document.body).getByRole('dialog') + const queryClient = createQueryClient() + const view = await testEnv.render( + + + + ) + + try { + await waitFor(() => { assert.ok( - (currentDialog.textContent ?? '').includes( - 'This reconciliation record changed while the dialog was open.' - ) + within(view.container).getByRole('button', { + name: 'Review and close selected (0)', + }) ) - return currentDialog }) - const staleDialogScreen = within(staleDialog) - assert.equal(staleDialog, dialog) - assert.equal( - staleDialog.querySelector('#manual-task-actual-quota'), - quotaInput + const checkboxes = taskItems.map((item) => + within(view.container).getByRole('checkbox', { + name: `Select billing reconciliation alert ${item.id}`, + }) ) - assert.equal( - staleDialog.querySelector('#manual-task-audit-note'), - noteInput + await view.click(checkboxes[0]) + await view.click(checkboxes[1]) + await view.click( + within(view.container).getByRole('button', { + name: 'Enter exact quotas (2)', + }) ) + + const firstInput = await waitFor(() => { + const input = document.getElementById( + 'manual-task-actual-quota-101' + ) as HTMLInputElement | null + assert.ok(input) + return input + }) + const secondInput = document.getElementById( + 'manual-task-actual-quota-102' + ) as HTMLInputElement + assert.ok(secondInput) + assert.equal(useSystemConfigStore.getState().loading, false) + assert.equal(firstInput.disabled, false) + assert.equal(secondInput.disabled, false) + assert.equal(firstInput.value, '') + assert.equal(secondInput.value, '') + assert.equal(within(document.body).queryByText('Loading...'), null) assert.equal( - staleDialogScreen - .getByRole('button', { name: 'Apply exact settlement' }) - .hasAttribute('disabled'), - true + within(document.body).queryByText( + 'This reconciliation record changed while the dialog was open.' + ), + null ) + const setInputValue = (input: HTMLInputElement, value: string): void => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + assert.ok(setter) + setter.call(input, value) + fireEvent.input(input) + } + await act(async () => { + setInputValue(firstInput, '') + }) + await waitFor(() => { + assert.ok( + within(document.body).getByText('Enter the exact final quota.') + ) + assert.equal(firstInput.getAttribute('aria-invalid'), 'true') + const submitButton = within(document.body).getByRole('button', { + name: 'Apply exact settlements', + }) as HTMLButtonElement + assert.equal(submitButton.disabled, true) + }) + await act(async () => { + setInputValue(firstInput, '101') + }) + await waitFor(() => { + assert.ok( + within(document.body).getByText( + 'Final quota cannot exceed the reserved quota.' + ) + ) + const submitButton = within(document.body).getByRole('button', { + name: 'Apply exact settlements', + }) as HTMLButtonElement + assert.equal(submitButton.disabled, true) + }) + await act(async () => { + setInputValue(firstInput, '0') + setInputValue(secondInput, '40') + }) + await waitFor(() => { + assert.equal(firstInput.value, '0') + assert.equal(secondInput.value, '40') + const submitButton = within(document.body).getByRole('button', { + name: 'Apply exact settlements', + }) as HTMLButtonElement + assert.equal(submitButton.disabled, false) + }) + await view.click( + within(document.body).getByRole('button', { + name: 'Apply exact settlements', + }) + ) + + await waitFor(() => { + assert.deepEqual(writes, [ + { + url: '/api/smart-ops/billing-settlements/complete-tasks', + data: { + items: [ + { id: 101, revision: 2, actual_quota: 0 }, + { id: 102, revision: 3, actual_quota: 40 }, + ], + }, + }, + ]) + }) } finally { api.get = originalGet + api.post = originalPost + await view.unmount() + queryClient.clear() + useAuthStore.getState().auth.setUser(originalUser) + useSystemConfigStore.getState().setLoading(originalSystemConfigLoading) delete htmlElementPrototype.attachEvent delete htmlElementPrototype.detachEvent + } + }) + + test('blocks zero-quota settlement when the confirmation revision becomes stale', async (): Promise => { + const originalPost = api.post + const writes: Array<{ url: string; data: unknown }> = [] + api.post = (async (url: string, data: unknown): Promise => { + writes.push({ url: String(url), data }) + return { + data: { + success: true, + data: { + completed_count: 1, + failed_count: 0, + settlement_ids: [111], + failed: [], + }, + }, + } + }) as typeof api.post + + const taskItem = ( + revision: number + ): BillingSettlementReconciliationItem => ({ + id: 111, + revision, + operation_key: 'task:7111:finalize', + status: 'manual' as const, + source: 'wallet' as const, + user_id: 53, + subscription_id: 0, + token_id: 54, + task_id: 7111, + task_quota: 100, + task_quota_target: 100, + requires_manual_completion: true, + zero_quota_eligible: true, + funding_delta: 0, + applied_funding_delta: 0, + token_delta: 0, + applied_token_delta: 0, + attempts: 0, + last_error: 'provider usage needs verification', + next_attempt: 0, + created_at: 1786032545, + updated_at: 1786032545, + reconciliation_reviewed_at: 0, + reconciliation_reviewed_by: 0, + reconciliation_review_note: '', + user_blocking_override: null, + record_blocks_user: false, + blocks_user: false, + }) + + function Harness(): ReactElement { + const [revision, setRevision] = useState(2) + const data = useMemo( + () => ({ + ...emptyReconciliationData(), + total_count: 1, + manual_count: 1, + open_alert_count: 1, + items: [taskItem(revision)], + }), + [revision] + ) + return ( + <> + + undefined} + /> + + ) + } + + const queryClient = createQueryClient() + const view = await testEnv.render( + + + + ) + + try { + const checkbox = within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 111', + }) + await view.click(checkbox) + await waitFor(() => { + assert.ok( + within(view.container).getByRole('button', { + name: 'Confirm zero-quota settlements (1)', + }) + ) + }) + const exactQuotaBatchButton = within(view.container).getByRole('button', { + name: 'Enter exact quotas (1)', + }) + const zeroQuotaBatchButton = within(view.container).getByRole('button', { + name: 'Confirm zero-quota settlements (1)', + }) + const actionButtons = Array.from( + view.container.querySelectorAll('button') + ).filter((button) => + [exactQuotaBatchButton, zeroQuotaBatchButton].includes(button) + ) + assert.equal(actionButtons[0], exactQuotaBatchButton) + await view.click(exactQuotaBatchButton) + await waitFor(() => { + assert.ok(document.getElementById('manual-task-actual-quota-111')) + }) + await view.click( + within(document.body).getByRole('button', { name: 'Cancel' }) + ) + await view.click(zeroQuotaBatchButton) + await waitFor(() => { + assert.ok( + within(document.body).getByRole('button', { + name: 'Apply zero-quota settlements', + }) + ) + }) + + const refreshButton = view.container.querySelector( + 'button' + ) as HTMLButtonElement | null + assert.ok(refreshButton) + fireEvent.click(refreshButton) + await waitFor(() => { + const confirm = within(document.body).getByRole('button', { + name: 'Apply zero-quota settlements', + }) as HTMLButtonElement + assert.equal(confirm.disabled, true) + assert.ok( + within(document.body).getByText( + 'This reconciliation record changed while the dialog was open.' + ) + ) + }) + await view.click( + within(document.body).getByRole('button', { + name: 'Apply zero-quota settlements', + }) + ) + assert.deepEqual(writes, []) + + await view.click( + within(document.body).getByRole('button', { name: 'Cancel' }) + ) + await view.click( + within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 111', + }) + ) + await waitFor(() => { + assert.ok( + within(view.container).getByRole('button', { + name: 'Confirm zero-quota settlements (1)', + }) + ) + }) + await view.click( + within(view.container).getByRole('button', { + name: 'Confirm zero-quota settlements (1)', + }) + ) + await view.click( + within(document.body).getByRole('button', { + name: 'Apply zero-quota settlements', + }) + ) + await waitFor(() => { + assert.deepEqual(writes, [ + { + url: '/api/smart-ops/billing-settlements/complete-tasks-zero', + data: { items: [{ id: 111, revision: 3 }] }, + }, + ]) + }) + } finally { + api.post = originalPost await view.unmount() queryClient.clear() - useAuthStore.getState().auth.setUser(originalUser) } }) diff --git a/web/default/src/features/smart-ops/api.ts b/web/default/src/features/smart-ops/api.ts index 62794406..d5b5e272 100644 --- a/web/default/src/features/smart-ops/api.ts +++ b/web/default/src/features/smart-ops/api.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ +import { z } from 'zod' import { api } from '@/lib/api' import { MAX_PERFORMANCE_LIMIT } from './lib/filters' import type { @@ -23,6 +24,7 @@ import type { BillingSettlementMutationResponse, BillingSettlementReviewRequest, ManualTaskBillingCompletionRequest, + ManualTaskBillingBatchCompletionRequest, ChannelPerformanceData, ChannelPerformanceQuery, ChannelPerformanceResponse, @@ -34,6 +36,36 @@ import type { SmartOpsAlertsResponse, } from './types' +const manualTaskBillingBatchCompletionDataSchema = z.object({ + completed_count: z.number().int().nonnegative(), + failed_count: z.number().int().nonnegative(), + settlement_ids: z.array(z.number().int().positive()), + failed: z.array( + z.object({ + settlement_id: z.number().int().positive(), + code: z.string().optional(), + message: z.string().optional(), + }) + ), +}) + +const manualTaskBillingBatchCompletionResponseSchema = z.union([ + z.object({ + success: z.literal(true), + message: z.string().optional(), + data: manualTaskBillingBatchCompletionDataSchema, + }), + z.object({ + success: z.literal(false), + message: z.string().optional(), + data: manualTaskBillingBatchCompletionDataSchema.optional(), + }), +]) + +type ManualTaskBillingBatchCompletionResponse = z.infer< + typeof manualTaskBillingBatchCompletionResponseSchema +> + export async function getSmartOpsAlerts(): Promise { const response = await api.get( '/api/smart-ops/alerts' @@ -82,6 +114,34 @@ export async function completeManualTaskBillingSettlement( return response.data } +export async function completeManualTaskBillingSettlements( + request: ManualTaskBillingBatchCompletionRequest +): Promise { + const response = await api.post( + '/api/smart-ops/billing-settlements/complete-tasks', + request, + { + skipBusinessError: true, + skipErrorHandler: true, + } + ) + return manualTaskBillingBatchCompletionResponseSchema.parse(response.data) +} + +export async function completeManualTaskBillingSettlementsZero( + request: BillingSettlementReviewRequest +): Promise { + const response = await api.post( + '/api/smart-ops/billing-settlements/complete-tasks-zero', + request, + { + skipBusinessError: true, + skipErrorHandler: true, + } + ) + return manualTaskBillingBatchCompletionResponseSchema.parse(response.data) +} + export async function getChannelPerformance( query: ChannelPerformanceQuery ): Promise { diff --git a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx index add20cee..77705ada 100644 --- a/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx +++ b/web/default/src/features/smart-ops/components/billing-settlement-evidence.tsx @@ -18,6 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API */ import { useMemo, useState, type ReactElement } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { TFunction } from 'i18next' import { CheckCircle2, Loader2, TriangleAlert } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -34,25 +35,86 @@ import { } from '@/components/ui/field' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' +import { ConfirmDialog } from '@/components/confirm-dialog' import { completeManualTaskBillingSettlement, reviewBillingSettlements, updateBillingSettlementBlockingPolicy, } from '../api' +import { useManualTaskBatchSettlement } from '../hooks/use-manual-task-batch-settlement' import { formatCount, formatLocalizedCount } from '../lib/format' import { mutationErrorMessage } from '../lib/mutation-error' import { SMART_OPS_ACTIVE_ALERTS_QUERY_KEY, SMART_OPS_BILLING_RECONCILIATION_QUERY_KEY, } from '../lib/query-keys' +import { + classifyBillingSettlementReviewSelection, + type BillingSettlementReviewSelection, +} from '../lib/reconciliation-validation' import type { BillingSettlementReconciliationData, BillingSettlementReconciliationItem, + ManualTaskBillingBatchFailure, BillingSettlementReviewTarget, } from '../types' import { BillingSettlementTable } from './billing-settlement-table' +import { ManualTaskSettlementBatchDialog } from './manual-task-settlement-batch-dialog' import { ManualTaskSettlementDialog } from './manual-task-settlement-dialog' +function formatBatchFailureMessage( + failure: ManualTaskBillingBatchFailure, + t: TFunction +): string { + const code = failure.code + switch (code) { + case 'not_attempted': + return t( + 'not attempted because another selected settlement failed validation' + ) + case 'minimax_h3_required': + return t( + 'Only MiniMax-H3 task settlements can use the zero-quota batch action' + ) + case 'record_conflict': + return t( + 'manual task billing settlement could not be applied safely; refresh and reconcile the current record' + ) + case 'invalid_settlement_request': + return t( + 'the supplied final quota or the settlement snapshot is invalid for this record' + ) + case 'token_quota_inconsistent': + return t( + 'the token quota mirror is inconsistent; repair the token record before completing this settlement' + ) + case 'subscription_refund_clamped': + return t( + 'the subscription usage mirror is lower than the refund; repair the subscription record before completing this settlement' + ) + case 'subscription_reservation_invalid': + return t( + 'the subscription reservation is unbound or its period changed; escalate this record for manual reconciliation' + ) + case 'manual_review_required': + return t('record still requires a manual financial review') + default: + return t('Failed to complete manual task billing.') + } +} + +function getBatchReviewActionKey( + kind: BillingSettlementReviewSelection +): string { + if (kind === 'mixed') { + return 'Select either task settlements or ordinary alerts, not both.' + } + if (kind === 'zero_quota' || kind === 'exact_quota') { + return 'Enter exact quotas ({{count}})' + } + return 'Review and close selected ({{count}})' +} + interface BillingSettlementEvidenceProps { canCompleteManualTask: boolean canUpdateBlockingPolicy: boolean @@ -71,6 +133,19 @@ export function BillingSettlementEvidence( >(() => new Map()) const [manualTaskItem, setManualTaskItem] = useState(null) + const [zeroSettlementTargets, setZeroSettlementTargets] = useState< + BillingSettlementReviewTarget[] + >([]) + const { + manualTaskBatchItems, + setManualTaskBatchItems, + batchFailures, + setBatchFailures, + zeroSettlementMutation, + manualTaskBatchCompletionMutation, + } = useManualTaskBatchSettlement({ + onSelectionCleared: () => setSelectedTargets(new Map()), + }) const reconciliationItems = props.data?.items const currentManualTaskItem = useMemo(() => { if (!manualTaskItem) return null @@ -86,6 +161,35 @@ export function BillingSettlementEvidence( currentManualTaskItem.revision !== manualTaskItem.revision || !currentManualTaskItem.requires_manual_completion) ) + const manualTaskBatchStale = useMemo(() => { + if (manualTaskBatchItems.length === 0) return false + return manualTaskBatchItems.some((selected) => { + const current = reconciliationItems?.find( + (item) => item.id === selected.id + ) + return ( + Boolean(props.error) || + !props.data || + !current || + current.revision !== selected.revision || + !current.requires_manual_completion + ) + }) + }, [manualTaskBatchItems, props.data, props.error, reconciliationItems]) + const zeroSettlementStale = useMemo(() => { + if (zeroSettlementTargets.length === 0) return false + return zeroSettlementTargets.some((target) => { + const current = reconciliationItems?.find((item) => item.id === target.id) + return ( + Boolean(props.error) || + !props.data || + !current || + current.revision !== target.revision || + !current.requires_manual_completion || + current.zero_quota_eligible !== true + ) + }) + }, [props.data, props.error, reconciliationItems, zeroSettlementTargets]) const { activeSelectedTargets, activeSelectedTargetMap } = useMemo(() => { const currentRevisions = new Map( reconciliationItems?.map((item) => [item.id, item.revision]) ?? [] @@ -100,6 +204,24 @@ export function BillingSettlementEvidence( ), } }, [reconciliationItems, selectedTargets]) + const selectedManualItems = useMemo( + () => + (reconciliationItems ?? []).filter( + (item) => + item.requires_manual_completion && + activeSelectedTargetMap.get(item.id)?.revision === item.revision + ), + [activeSelectedTargetMap, reconciliationItems] + ) + const selectedReviewKind = useMemo( + () => + classifyBillingSettlementReviewSelection( + (reconciliationItems ?? []).filter((item) => + activeSelectedTargetMap.has(item.id) + ) + ), + [activeSelectedTargetMap, reconciliationItems] + ) const [policyOverride, setPolicyOverride] = useState(null) const queryClient = useQueryClient() const policyValue = @@ -184,8 +306,36 @@ export function BillingSettlementEvidence( }) const reviewTargets = (targets: BillingSettlementReviewTarget[]): void => { - if (targets.length > 0 && !reviewMutation.isPending) { - reviewMutation.mutate(targets) + if ( + targets.length === 0 || + reviewMutation.isPending || + zeroSettlementMutation.isPending || + manualTaskCompletionMutation.isPending || + manualTaskBatchCompletionMutation.isPending + ) + return + const selectedItems = (reconciliationItems ?? []).filter((item) => + targets.some( + (target) => target.id === item.id && target.revision === item.revision + ) + ) + switch (classifyBillingSettlementReviewSelection(selectedItems)) { + case 'exact_quota': + setManualTaskBatchItems(selectedItems) + return + case 'mixed': + toast.error( + t('Select either task settlements or ordinary alerts, not both.') + ) + return + case 'zero_quota': + setZeroSettlementTargets(targets) + return + case 'ordinary': + reviewMutation.mutate(targets) + return + default: + return } } @@ -194,16 +344,13 @@ export function BillingSettlementEvidence( mutationFn: async ({ item, actualQuota, - note, }: { item: BillingSettlementReconciliationItem actualQuota: number - note: string }): Promise => { const response = await completeManualTaskBillingSettlement(item.id, { revision: item.revision, actual_quota: actualQuota, - note, }) if (!response.success) { throw new Error( @@ -211,8 +358,11 @@ export function BillingSettlementEvidence( ) } }, - onSuccess: () => { + onSuccess: (_data, variables) => { setManualTaskItem(null) + setBatchFailures((current) => + current.filter((failure) => failure.settlement_id !== variables.item.id) + ) toast.success(t('Manual task billing completed.')) }, onSettled: async () => { @@ -245,12 +395,37 @@ export function BillingSettlementEvidence( onOpenChange={(open) => { if (!open) setManualTaskItem(null) }} - onSubmit={(item, actualQuota, note) => - manualTaskCompletionMutation.mutate({ item, actualQuota, note }) + onSubmit={(item, actualQuota) => + manualTaskCompletionMutation.mutate({ item, actualQuota }) } /> ) : null + const manualTaskBatchDialog = + manualTaskBatchItems.length > 0 ? ( + `${item.id}:${item.revision}`) + .join(',')} + items={manualTaskBatchItems} + pending={manualTaskBatchCompletionMutation.isPending} + stale={manualTaskBatchStale} + onOpenChange={(open) => { + if (!open) setManualTaskBatchItems([]) + }} + onSubmit={(items, actualQuotas) => + manualTaskBatchCompletionMutation.mutate({ items, actualQuotas }) + } + /> + ) : null + + const confirmZeroSettlement = (): void => { + if (zeroSettlementStale) return + const targets = zeroSettlementTargets + setZeroSettlementTargets([]) + if (targets.length > 0) zeroSettlementMutation.mutate(targets) + } + const replaceSelectedTargets = ( targets: Map ): void => { @@ -304,7 +479,7 @@ export function BillingSettlementEvidence(

{t( - 'Batch-close ordinary alerts after review. Task-finalization alerts require a root administrator to enter the exact provider-backed quota before they can close.' + 'After selecting alerts, root administrators can either close eligible MiniMax-H3 task settlements with an explicit final quota of 0 or enter an exact final quota for each selected task. Ordinary alerts can be closed directly after review.' )}

@@ -391,33 +566,90 @@ export function BillingSettlementEvidence( + {selectedReviewKind === 'zero_quota' && + selectedManualItems.length > 0 && + selectedManualItems.length === activeSelectedTargets.length && ( + + )} + {batchFailures.length > 0 && ( + + + )} {content} {manualTaskDialog} + {manualTaskBatchDialog} + 0} + onOpenChange={(open) => { + if (!open && !zeroSettlementMutation.isPending) { + setZeroSettlementTargets([]) + } + }} + title={t('Confirm zero-quota settlement')} + desc={t( + 'This will settle {{displayCount}} selected MiniMax-H3 task(s) with an explicit final quota of 0 and refund the unused reservation. Continue only after verifying the provider result.', + { + count: zeroSettlementTargets.length, + displayCount: formatCount( + zeroSettlementTargets.length, + i18n.language + ), + } + )} + confirmText={t('Apply zero-quota settlements')} + handleConfirm={confirmZeroSettlement} + disabled={zeroSettlementStale} + isLoading={zeroSettlementMutation.isPending} + > + {zeroSettlementStale && ( + + + {t( + 'This reconciliation record changed while the dialog was open.' + )} + + + {t( + 'Close this dialog and reopen the latest record before submitting.' + )} + + + )} + ) } diff --git a/web/default/src/features/smart-ops/components/billing-settlement-table.tsx b/web/default/src/features/smart-ops/components/billing-settlement-table.tsx index 8cfbb64a..15eb5f2d 100644 --- a/web/default/src/features/smart-ops/components/billing-settlement-table.tsx +++ b/web/default/src/features/smart-ops/components/billing-settlement-table.tsx @@ -35,7 +35,10 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { useBillingSettlementSelection } from '../hooks/use-billing-settlement-selection' +import { + isManualSettlementSelectable, + useBillingSettlementSelection, +} from '../hooks/use-billing-settlement-selection' import { formatLocalizedCount } from '../lib/format' import type { BillingSettlementReconciliationItem, @@ -78,6 +81,36 @@ function BillingSettlementActionsCell( const { t } = useTranslation() if (props.item.requires_manual_completion) { + if ( + props.item.zero_quota_eligible === true && + props.canCompleteManualTask + ) { + return ( +
+ + +
+ ) + } return (
{!props.canCompleteManualTask && ( @@ -130,12 +163,13 @@ export function BillingSettlementTable( props: BillingSettlementTableProps ): ReactElement { const { t, i18n } = useTranslation() - const hasReviewableItems = props.items.some( - (item) => !item.requires_manual_completion + const hasReviewableItems = props.items.some((item) => + isManualSettlementSelectable(item, props.canCompleteManualTask) ) const { allSelected, someSelected, isSelected, toggleAll, toggleItem } = useBillingSettlementSelection({ items: props.items, + canSelectManualTask: props.canCompleteManualTask, selectedTargets: props.selectedTargets, onSelectedTargetsChange: props.onSelectedTargetsChange, }) @@ -182,7 +216,11 @@ export function BillingSettlementTable( toggleItem(item, checked === true) } disabled={ - props.reviewPending || item.requires_manual_completion + props.reviewPending || + !isManualSettlementSelectable( + item, + props.canCompleteManualTask + ) } aria-label={t( 'Select billing reconciliation alert {{id}}', @@ -207,11 +245,12 @@ export function BillingSettlementTable( {item.status === 'manual' ? t('Manual') : t('Pending')} {t('Open alert')} - {item.requires_manual_completion && ( - - {t('Exact quota required')} - - )} + {item.requires_manual_completion && + item.zero_quota_eligible !== true && ( + + {t('Exact quota required')} + + )}
diff --git a/web/default/src/features/smart-ops/components/manual-task-settlement-batch-dialog.tsx b/web/default/src/features/smart-ops/components/manual-task-settlement-batch-dialog.tsx new file mode 100644 index 00000000..56b63361 --- /dev/null +++ b/web/default/src/features/smart-ops/components/manual-task-settlement-batch-dialog.tsx @@ -0,0 +1,247 @@ +/* +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 { useMemo, useRef, type ReactElement, type SyntheticEvent } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { formatNumber, formatQuota } from '@/lib/format' +import { useSystemConfig } from '@/hooks/use-system-config' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from '@/components/ui/field' +import { Form, FormField } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + getManualTaskSettlementBatchSchema, + type ManualTaskSettlementBatchFormValues, +} from '../lib/manual-task-settlement' +import type { BillingSettlementReconciliationItem } from '../types' + +interface ManualTaskSettlementBatchDialogProps { + items: BillingSettlementReconciliationItem[] + pending: boolean + stale: boolean + onOpenChange: (open: boolean) => void + onSubmit: ( + items: BillingSettlementReconciliationItem[], + actualQuotas: Record + ) => void +} + +export function ManualTaskSettlementBatchDialog( + props: ManualTaskSettlementBatchDialogProps +): ReactElement { + const { t } = useTranslation() + const { currency, loading: configLoading } = useSystemConfig() + const maxQuotas = useMemo( + () => props.items.map((item) => item.task_quota), + [props.items] + ) + const schema = useMemo( + () => getManualTaskSettlementBatchSchema(t, maxQuotas), + [maxQuotas, t] + ) + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + items: props.items.map(() => ({ actualQuota: '' })), + }, + mode: 'onChange', + }) + // Base UI emits native input events in the jsdom/test path while React's + // controlled-input contract expects onChange. Share one deduplicated + // handler so both paths update RHF without validating the same event twice. + const handledNativeEvents = useRef(new WeakSet()) + const { fields } = useFieldArray({ + control: form.control, + name: 'items', + }) + + const canSubmit = + props.items.length > 0 && + !props.pending && + !props.stale && + !configLoading && + form.formState.isValid + + const handleSubmit = form.handleSubmit((values) => { + const actualQuotas = Object.fromEntries( + props.items.map((item, index) => [ + item.id, + Number(values.items[index]?.actualQuota), + ]) + ) as Record + props.onSubmit(props.items, actualQuotas) + }) + + return ( + 0} + onOpenChange={(open) => { + if (!props.pending) props.onOpenChange(open) + }} + > + + + {t('Complete manual task billing')} + + {t( + 'Enter an exact final quota for each selected task. Each value is checked against its own reserved quota.' + )} + + + + {configLoading && ( + + {t('Loading...')} + + )} + {props.stale && ( + + + {t( + 'This reconciliation record changed while the dialog was open.' + )} + + + {t( + 'Close this dialog and reopen the latest record before submitting.' + )} + + + )} + +
+ + + {fields.map((field, index) => { + const item = props.items[index] + if (!item) return null + return ( + { + const { onChange, ...fieldProps } = inputField + const handleFieldChange = ( + event: SyntheticEvent + ): void => { + const nativeEvent = event.nativeEvent + if (handledNativeEvents.current.has(nativeEvent)) { + return + } + handledNativeEvents.current.add(nativeEvent) + onChange(event) + } + return ( + + + {t('Task #{{id}} · reserved {{quota}}', { + id: item.task_id, + quota: formatQuota(item.task_quota), + })} + + + + {t( + 'Allowed range: 0 to {{quota}} ({{quotaPerUnit}} quota = $1).', + { + quota: formatQuota(item.task_quota), + quotaPerUnit: formatNumber( + currency.quotaPerUnit + ), + } + )} + + {fieldState.error && ( + {fieldState.error.message} + )} + + ) + }} + /> + ) + })} + +
+ + + + + + +
+
+ ) +} diff --git a/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx b/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx index d1740d6a..e45f374c 100644 --- a/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx +++ b/web/default/src/features/smart-ops/components/manual-task-settlement-dialog.tsx @@ -21,7 +21,8 @@ import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Loader2 } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { formatQuota } from '@/lib/format' +import { formatNumber, formatQuota } from '@/lib/format' +import { useSystemConfig } from '@/hooks/use-system-config' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { @@ -41,7 +42,6 @@ import { } from '@/components/ui/field' import { Form, FormField } from '@/components/ui/form' import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' import { getManualTaskSettlementSchema, type ManualTaskSettlementFormValues, @@ -55,8 +55,7 @@ interface ManualTaskSettlementDialogProps { onOpenChange: (open: boolean) => void onSubmit: ( item: BillingSettlementReconciliationItem, - actualQuota: number, - note: string + actualQuota: number ) => void } @@ -64,20 +63,24 @@ export function ManualTaskSettlementDialog( props: ManualTaskSettlementDialogProps ): ReactElement { const { t } = useTranslation() + const { currency, loading: configLoading } = useSystemConfig() const item = props.item const form = useForm({ resolver: zodResolver( getManualTaskSettlementSchema(t, props.item?.task_quota ?? 0) ), - defaultValues: { actualQuota: '', note: '' }, + defaultValues: { actualQuota: '' }, mode: 'onChange', }) const canSubmit = - Boolean(props.item) && !props.stale && form.formState.isValid + Boolean(props.item) && + !props.stale && + !configLoading && + form.formState.isValid const handleSubmit = form.handleSubmit((values) => { if (props.item && !props.stale) { - props.onSubmit(props.item, Number(values.actualQuota), values.note) + props.onSubmit(props.item, Number(values.actualQuota)) } }) @@ -107,11 +110,6 @@ export function ManualTaskSettlementDialog( quota: formatQuota(item.task_quota), })} - - {t( - 'Only an exact quota from verified provider evidence is allowed. This workflow cannot add a charge above the original reservation.' - )} - {props.stale && ( @@ -148,42 +146,18 @@ export function ManualTaskSettlementDialog( min={0} max={item.task_quota} step={1} - disabled={props.pending || props.stale} - aria-invalid={fieldState.invalid} - /> - - {t('Allowed range: 0 to {{quota}}.', { - quota: formatQuota(item.task_quota), - })} - - {fieldState.error && ( - {fieldState.error.message} - )} - - )} - /> - - ( - - - {t('Audit note')} - -