From eaa8116adae704b46d4447284ba918fa3a759783 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 12:17:44 +0800 Subject: [PATCH 01/58] update --- model/task.go | 1 + model/task_manual_settlement_test.go | 36 ++ pkg/taskusage/taskusage.go | 337 ++++++++++++++++++ pkg/taskusage/taskusage_test.go | 125 +++++++ .../testdata/task-plugin-minimax-h3-v1.json | 14 + relay/channel/adapter.go | 9 + relay/channel/task/doubao/adaptor.go | 116 +++++- .../channel/task/doubao/adaptor_usage_test.go | 75 ++++ relay/channel/task/hailuo/adaptor.go | 38 +- relay/channel/task/hailuo/adaptor_test.go | 23 ++ relay/common/relay_info.go | 21 +- service/smart_ops_alert.go | 3 + service/task_billing.go | 121 ++++++- service/task_billing_h3_test.go | 294 ++++++++++++--- service/task_polling.go | 28 ++ service/task_polling_test.go | 70 ++++ setting/task_billing_setting/h3.go | 35 ++ setting/task_billing_setting/h3_test.go | 16 + types/task_billing_plan.go | 5 + types/task_usage.go | 87 +++++ 20 files changed, 1370 insertions(+), 84 deletions(-) create mode 100644 pkg/taskusage/taskusage.go create mode 100644 pkg/taskusage/taskusage_test.go create mode 100644 pkg/taskusage/testdata/task-plugin-minimax-h3-v1.json create mode 100644 relay/channel/task/doubao/adaptor_usage_test.go diff --git a/model/task.go b/model/task.go index 12629645..7757bb84 100644 --- a/model/task.go +++ b/model/task.go @@ -157,6 +157,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"` // 渠道关闭完成态差额结算时按提交快照跳过 diff --git a/model/task_manual_settlement_test.go b/model/task_manual_settlement_test.go index 01d67cdd..0b886900 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,41 @@ 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) + require.NotNil(t, stored.PrivateData.BillingContext.TaskUsageEnvelope) + require.Equal(t, "evidence-digest", stored.PrivateData.BillingContext.TaskUsageEnvelope.EvidenceDigest) + require.NotNil(t, stored.PrivateData.BillingContext.TaskUsageEnvelope.Usage.OutputDurationMs) + require.Zero(t, *stored.PrivateData.BillingContext.TaskUsageEnvelope.Usage.OutputDurationMs) +} + 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..698562d6 --- /dev/null +++ b/pkg/taskusage/taskusage.go @@ -0,0 +1,337 @@ +package taskusage + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/MAX-API-Next/MAX-API/types" +) + +const ( + SourceIDMiniMax = "minimax" + SourceIDDoubaoVideo = "doubao_video" +) + +var canonicalFieldOrder = []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, min int64, max *int64) types.TaskUsageFieldContract { + return types.TaskUsageFieldContract{ + Key: key, Unit: unit, RequiredForTerminal: required, + MinValue: int64Pointer(min), MaxValue: max, + } +} + +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(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) error { + if envelope == nil { + return fmt.Errorf("task usage envelope is required") + } + expected, err := BuildEnvelope(envelope.ProducerKind, 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 || + envelope.EvidenceDigest != expected.EvidenceDigest { + 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) + } + } + } + + 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(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 canonicalFieldOrder { + fmt.Fprintf(&canonical, "%s=%s\n", key, pointerString(values[key])) + } + return sha256Hex(canonical.String()) +} + +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..ccb2e479 --- /dev/null +++ b/pkg/taskusage/taskusage_test.go @@ -0,0 +1,125 @@ +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)) +} + +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 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)) + + tampered = types.CloneTaskUsageEnvelope(envelope) + *tampered.Usage.TotalTokens = 1 + require.Error(t, ValidateEnvelope(DoubaoVideoContract(), tampered)) +} + +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)) +} 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..f00d14f3 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -89,6 +89,15 @@ 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 + 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..f96d8a29 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,11 @@ type responseTask struct { UpdatedAt int64 `json:"updated_at"` } +type responseTaskUsage struct { + CompletionTokens *int `json:"completion_tokens"` + TotalTokens *int `json:"total_tokens"` +} + // ============================ // Adaptor implementation // ============================ @@ -111,6 +113,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,6 +569,9 @@ 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 } } @@ -573,6 +580,10 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e if err := common.Unmarshal(respBody, &resTask); err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") } + envelope, err := buildDoubaoUsageEnvelope(resTask.Usage) + if err != nil { + return nil, err + } taskResult := relaycommon.TaskInfo{ Code: 0, @@ -591,8 +602,12 @@ 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 + if envelope.Usage != nil && envelope.Usage.CompletionTokens != nil { + taskResult.CompletionTokens = int(*envelope.Usage.CompletionTokens) + } + if envelope.Usage != nil && envelope.Usage.TotalTokens != nil { + taskResult.TotalTokens = int(*envelope.Usage.TotalTokens) + } case "failed": taskResult.Status = model.TaskStatusFailure taskResult.Progress = "100%" @@ -602,10 +617,93 @@ 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) 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) + } + var response responseTask + if err := common.Unmarshal(ctx.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 buildDoubaoUsageEnvelope(raw json.RawMessage) (*types.TaskUsageEnvelope, error) { + var parsed responseTaskUsage + completeness := types.TaskUsageCompletenessMissing + switch common.GetJsonType(raw) { + case "unknown", "null": + return taskusage.BuildEnvelope( + types.TaskUsageProducerKindGoAdapter, + taskusage.DoubaoVideoContract(), + types.TaskUsageSourceProviderResponse, + nil, + ) + 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 = nil + case parsed.CompletionTokens == nil || parsed.TotalTokens == nil: + usage.Completeness = types.TaskUsageCompletenessPartial + case *parsed.CompletionTokens < 0 || *parsed.TotalTokens < 0 || *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 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..c8dac199 --- /dev/null +++ b/relay/channel/task/doubao/adaptor_usage_test.go @@ -0,0 +1,75 @@ +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/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 + }{ + {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}, + {name: "negative", body: `{"status":"succeeded","usage":{"completion_tokens":-1,"total_tokens":2}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid}, + {name: "inconsistent", body: `{"status":"succeeded","usage":{"completion_tokens":4,"total_tokens":3}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid}, + } + 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) + }) + } +} + +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) +} + +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) +} diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index 9999f614..7f86e8a2 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,38 @@ 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) 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 + } + return taskusage.BuildEnvelope(types.TaskUsageProducerKindGoAdapter, a.UsageContract(), ctx.Stage, usage) +} + +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 { + return nil, 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..e4b4fb18 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -507,6 +507,29 @@ 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) +} + func TestExtractTaskUsageIgnoresNonObjectGenericUsage(t *testing.T) { for _, value := range []string{`null`, `[]`, `"none"`, `123`} { t.Run(value, func(t *testing.T) { diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 74c3b174..5f484f70 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -925,16 +925,17 @@ 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"` + 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/service/smart_ops_alert.go b/service/smart_ops_alert.go index 46622b3b..1c8a4f2d 100644 --- a/service/smart_ops_alert.go +++ b/service/smart_ops_alert.go @@ -520,6 +520,9 @@ func CompleteManualTaskBillingSettlement( if input == nil { return ManualTaskBillingCompletionResult{}, 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 { diff --git a/service/task_billing.go b/service/task_billing.go index 93a40b87..e46967e4 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,16 @@ func prepareTaskTerminalBillingDecision( } } - usage := frozenTaskUsage(task, taskResult) + var usageEnvelope *types.TaskUsageEnvelope + if taskBillingPlanHasUsageIdentity(plan) { + usageEnvelope = frozenTaskUsageEnvelope(task, taskResult) + } + usage := frozenTaskUsage(task, taskResult, usageEnvelope) 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 +424,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 contract requires manual reconciliation: " + 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 +442,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 +453,27 @@ 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 { + return types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + } + return types.CloneTaskUsageEnvelope(frozen) + } + if taskResult != nil && taskResult.UsageEnvelope != nil { + return types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + } + return nil +} + +func frozenTaskUsage(task *model.Task, taskResult *relaycommon.TaskInfo, envelope *types.TaskUsageEnvelope) *types.TaskUsage { + if envelope != nil { + return types.CloneTaskUsage(envelope.Usage) + } 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 +498,52 @@ 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); err != nil { + return fmt.Errorf("usage envelope validation failed: %w", err) + } + return nil +} + +func taskBillingPlanHasUsageIdentity(plan *types.TaskBillingPlan) bool { + return plan != nil && (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..e7818b40 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) @@ -138,7 +174,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 +188,168 @@ 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.ErrorContains(t, errors.New(decision.ManualReason), "usage envelope identity") + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) + require.Nil(t, decision.Settlement.Effect) +} + +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) + require.ErrorContains(t, errors.New(decision.ManualReason), "provider_response") + require.NotNil(t, decision.Settlement) + require.Zero(t, decision.Settlement.FundingDelta) +} + +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 +359,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 +368,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 +397,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 +568,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 +624,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 +638,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 +677,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 +691,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 +703,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 +787,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 +801,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 +850,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 +986,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 +1053,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 +1094,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 +1126,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 +1166,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..4713c10b 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,11 @@ type taskUsageProvider interface { ExtractTaskUsage(responseBody []byte) (*types.TaskUsage, error) } +type taskUsageFactProvider interface { + UsageContract() types.TaskUsageContract + ProduceUsage(ctx types.TaskUsageContext) (*types.TaskUsageEnvelope, error) +} + // GetTaskAdaptorFunc 由 main 包注入,用于获取指定平台的任务适配器。 // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor @@ -645,6 +651,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 +858,25 @@ 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); 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 @@ -894,6 +920,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 +958,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..d257a15c 100644 --- a/service/task_polling_test.go +++ b/service/task_polling_test.go @@ -14,6 +14,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,6 +48,13 @@ type usagePollingResponseAdaptor struct { usageErr error } +type usageFactPollingResponseAdaptor struct { + contract types.TaskUsageContract + envelope *types.TaskUsageEnvelope + err error + calls int +} + type configuredWrapperPollingAdaptor struct{} func (a *configuredWrapperPollingAdaptor) Init(*relaycommon.RelayInfo) {} @@ -96,6 +104,29 @@ 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) 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,6 +148,45 @@ 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 TestUpdateVideoSingleTaskUsesConfiguredParserForWrappedProviderResult(t *testing.T) { truncate(t) baseURL := "https://upstream.example.com" diff --git a/setting/task_billing_setting/h3.go b/setting/task_billing_setting/h3.go index 96052bde..542626a0 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,31 @@ 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 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..4d3d754e 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,21 @@ 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, H3BillingSource, 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 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 } From fae26809a1d7d0df4737d95e1165dd8eba2a4ff8 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 13:20:21 +0800 Subject: [PATCH 02/58] fix: harden task usage producer and wrapped payloads --- pkg/taskusage/taskusage.go | 14 +++++++++---- pkg/taskusage/taskusage_test.go | 21 +++++++++++++++---- relay/channel/task/doubao/adaptor.go | 10 ++++++++- .../channel/task/doubao/adaptor_usage_test.go | 18 ++++++++++++++++ service/task_billing.go | 2 +- service/task_polling.go | 2 +- 6 files changed, 56 insertions(+), 11 deletions(-) diff --git a/pkg/taskusage/taskusage.go b/pkg/taskusage/taskusage.go index 698562d6..cbbf3c9b 100644 --- a/pkg/taskusage/taskusage.go +++ b/pkg/taskusage/taskusage.go @@ -89,10 +89,10 @@ func ResolveContract(sourceID string, schemaVersion int, contractDigest string) return contract, nil } -func usageField(key, unit string, required bool, min int64, max *int64) types.TaskUsageFieldContract { +func usageField(key, unit string, required bool, minValue int64, max *int64) types.TaskUsageFieldContract { return types.TaskUsageFieldContract{ Key: key, Unit: unit, RequiredForTerminal: required, - MinValue: int64Pointer(min), MaxValue: max, + MinValue: int64Pointer(minValue), MaxValue: max, } } @@ -161,11 +161,17 @@ func BuildEnvelope(producerKind string, contract types.TaskUsageContract, stage // 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) error { +func ValidateEnvelope(contract types.TaskUsageContract, envelope *types.TaskUsageEnvelope, expectedProducerKind string) error { if envelope == nil { return fmt.Errorf("task usage envelope is required") } - expected, err := BuildEnvelope(envelope.ProducerKind, contract, envelope.Stage, envelope.Usage) + 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 } diff --git a/pkg/taskusage/taskusage_test.go b/pkg/taskusage/taskusage_test.go index ccb2e479..80dbdc94 100644 --- a/pkg/taskusage/taskusage_test.go +++ b/pkg/taskusage/taskusage_test.go @@ -31,7 +31,7 @@ func TestBuildEnvelopeDistinguishesAbsentAndExplicitZero(t *testing.T) { 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)) + require.NoError(t, ValidateEnvelope(contract, explicitZero, types.TaskUsageProducerKindGoAdapter)) } func TestEvidenceDigestIsCanonicalAcrossProducerKindsAndFieldOrder(t *testing.T) { @@ -79,11 +79,24 @@ func TestValidateEnvelopeRejectsIdentityAndDigestTampering(t *testing.T) { tampered := types.CloneTaskUsageEnvelope(envelope) tampered.SourceID = "client-supplied-source" - require.Error(t, ValidateEnvelope(DoubaoVideoContract(), tampered)) + require.Error(t, ValidateEnvelope(DoubaoVideoContract(), tampered, types.TaskUsageProducerKindGoAdapter)) tampered = types.CloneTaskUsageEnvelope(envelope) *tampered.Usage.TotalTokens = 1 - require.Error(t, ValidateEnvelope(DoubaoVideoContract(), tampered)) + 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) { @@ -121,5 +134,5 @@ func TestTaskPluginFixtureMatchesGoAdapterCanonicalFacts(t *testing.T) { 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)) + require.NoError(t, ValidateEnvelope(contract, pluginEnvelope, types.TaskUsageProducerKindTaskPlugin)) } diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index f96d8a29..89d05108 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -633,8 +633,16 @@ func (a *TaskAdaptor) ProduceUsage(ctx types.TaskUsageContext) (*types.TaskUsage if ctx.Stage != types.TaskUsageSourceProviderResponse { return nil, fmt.Errorf("Doubao task usage stage %q is not supported", ctx.Stage) } + payload := ctx.Payload + for depth := 0; depth < taskcommon.MaxWrappedTaskUnwrapDepth; depth++ { + nested := taskcommon.WrappedTaskProviderPayload(payload) + if nested == nil { + break + } + payload = nested + } var response responseTask - if err := common.Unmarshal(ctx.Payload, &response); err != nil { + if err := common.Unmarshal(payload, &response); err != nil { return nil, errors.Wrap(err, "unmarshal Doubao task usage failed") } return buildDoubaoUsageEnvelope(response.Usage) diff --git a/relay/channel/task/doubao/adaptor_usage_test.go b/relay/channel/task/doubao/adaptor_usage_test.go index c8dac199..cbe676f3 100644 --- a/relay/channel/task/doubao/adaptor_usage_test.go +++ b/relay/channel/task/doubao/adaptor_usage_test.go @@ -73,3 +73,21 @@ func TestParseTaskResultDoesNotLetNonObjectUsageHideTerminalResult(t *testing.T) require.Equal(t, types.TaskUsagePresenceInvalid, result.UsageEnvelope.Presence) require.Equal(t, types.TaskUsageCompletenessInvalid, result.UsageEnvelope.Completeness) } + +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) +} diff --git a/service/task_billing.go b/service/task_billing.go index e46967e4..70d6e62d 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -521,7 +521,7 @@ func validateTaskUsageEnvelopeForPlan(plan *types.TaskBillingPlan, envelope *typ if err != nil { return fmt.Errorf("resolve frozen usage contract: %w", err) } - if err := taskusage.ValidateEnvelope(contract, envelope); err != nil { + if err := taskusage.ValidateEnvelope(contract, envelope, plan.UsageProducerKind); err != nil { return fmt.Errorf("usage envelope validation failed: %w", err) } return nil diff --git a/service/task_polling.go b/service/task_polling.go index 4713c10b..07c93696 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -870,7 +870,7 @@ func applyTaskUsageFacts(adaptor TaskPollingAdaptor, responseBody []byte, taskRe return err } } - if err := taskusage.ValidateEnvelope(contract, envelope); err != nil { + if err := taskusage.ValidateEnvelope(contract, envelope, types.TaskUsageProducerKindGoAdapter); err != nil { return err } taskResult.UsageEnvelope = types.CloneTaskUsageEnvelope(envelope) From 1f69397149f09ad93671ae6c4da659db6f5f8aff Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 13:48:04 +0800 Subject: [PATCH 03/58] fix: enforce polling usage stage --- pkg/taskusage/taskusage.go | 4 ++-- service/task_polling.go | 3 +++ service/task_polling_test.go | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/pkg/taskusage/taskusage.go b/pkg/taskusage/taskusage.go index cbbf3c9b..501e4f7c 100644 --- a/pkg/taskusage/taskusage.go +++ b/pkg/taskusage/taskusage.go @@ -89,10 +89,10 @@ func ResolveContract(sourceID string, schemaVersion int, contractDigest string) return contract, nil } -func usageField(key, unit string, required bool, minValue int64, max *int64) types.TaskUsageFieldContract { +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: max, + MinValue: int64Pointer(minValue), MaxValue: maxValue, } } diff --git a/service/task_polling.go b/service/task_polling.go index 07c93696..00bd5234 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -873,6 +873,9 @@ func applyTaskUsageFacts(adaptor TaskPollingAdaptor, responseBody []byte, taskRe if err := taskusage.ValidateEnvelope(contract, envelope, types.TaskUsageProducerKindGoAdapter); err != nil { return err } + if envelope.Stage != types.TaskUsageSourceProviderResponse { + return fmt.Errorf("task usage envelope stage %q is not supported for polling", envelope.Stage) + } taskResult.UsageEnvelope = types.CloneTaskUsageEnvelope(envelope) taskResult.Usage = types.CloneTaskUsage(envelope.Usage) return nil diff --git a/service/task_polling_test.go b/service/task_polling_test.go index d257a15c..1a262bec 100644 --- a/service/task_polling_test.go +++ b/service/task_polling_test.go @@ -187,6 +187,23 @@ func TestApplyTaskUsageFactsRejectsTamperedEnvelope(t *testing.T) { 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 TestUpdateVideoSingleTaskUsesConfiguredParserForWrappedProviderResult(t *testing.T) { truncate(t) baseURL := "https://upstream.example.com" From 84e57b28e2eadc21b3291a00a4cb2f397413099a Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 15:37:20 +0800 Subject: [PATCH 04/58] fix: reject inconsistent task token usage --- pkg/taskusage/taskusage.go | 7 +++++++ pkg/taskusage/taskusage_test.go | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/pkg/taskusage/taskusage.go b/pkg/taskusage/taskusage.go index 501e4f7c..aab622a7 100644 --- a/pkg/taskusage/taskusage.go +++ b/pkg/taskusage/taskusage.go @@ -234,6 +234,13 @@ func validateUsage(contract types.TaskUsageContract, usage *types.TaskUsage, com } } } + 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 diff --git a/pkg/taskusage/taskusage_test.go b/pkg/taskusage/taskusage_test.go index 80dbdc94..394f1911 100644 --- a/pkg/taskusage/taskusage_test.go +++ b/pkg/taskusage/taskusage_test.go @@ -34,6 +34,27 @@ func TestBuildEnvelopeDistinguishesAbsentAndExplicitZero(t *testing.T) { 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) From 32dc8976f00cb46ab9f10b6bfc9d473dbe6cf6e3 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 19:12:45 +0800 Subject: [PATCH 05/58] fix: harden task usage polling provenance --- relay/channel/adapter.go | 3 ++ relay/channel/task/doubao/adaptor.go | 9 +++- .../channel/task/doubao/adaptor_usage_test.go | 2 + relay/channel/task/hailuo/adaptor.go | 4 ++ service/task_billing.go | 5 +- service/task_billing_h3_test.go | 32 +++++++++++++ service/task_polling.go | 5 +- service/task_polling_test.go | 48 +++++++++++++++++-- 8 files changed, 101 insertions(+), 7 deletions(-) diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index f00d14f3..da74efaa 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -95,6 +95,9 @@ type TaskUsageProvider interface { // 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) } diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 89d05108..8744b36f 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -629,6 +629,10 @@ 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) @@ -696,9 +700,12 @@ func buildDoubaoUsageEnvelope(raw json.RawMessage) (*types.TaskUsageEnvelope, er switch { case parsed.CompletionTokens == nil && parsed.TotalTokens == nil: usage = nil + 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.CompletionTokens < 0 || *parsed.TotalTokens < 0 || *parsed.TotalTokens < *parsed.CompletionTokens: + case *parsed.TotalTokens < *parsed.CompletionTokens: usage.Completeness = types.TaskUsageCompletenessInvalid default: usage.Completeness = types.TaskUsageCompletenessComplete diff --git a/relay/channel/task/doubao/adaptor_usage_test.go b/relay/channel/task/doubao/adaptor_usage_test.go index cbe676f3..047a3782 100644 --- a/relay/channel/task/doubao/adaptor_usage_test.go +++ b/relay/channel/task/doubao/adaptor_usage_test.go @@ -32,6 +32,8 @@ func TestProduceUsageClassifiesDoubaoMissingPartialAndInvalid(t *testing.T) { }{ {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}, + {name: "single negative completion", body: `{"status":"succeeded","usage":{"completion_tokens":-1}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid}, + {name: "single negative total", body: `{"status":"succeeded","usage":{"total_tokens":-1}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid}, {name: "negative", body: `{"status":"succeeded","usage":{"completion_tokens":-1,"total_tokens":2}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid}, {name: "inconsistent", body: `{"status":"succeeded","usage":{"completion_tokens":4,"total_tokens":3}}`, presence: types.TaskUsagePresenceInvalid, completeness: types.TaskUsageCompletenessInvalid}, } diff --git a/relay/channel/task/hailuo/adaptor.go b/relay/channel/task/hailuo/adaptor.go index 7f86e8a2..a6d2f6b6 100644 --- a/relay/channel/task/hailuo/adaptor.go +++ b/relay/channel/task/hailuo/adaptor.go @@ -373,6 +373,10 @@ 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) diff --git a/service/task_billing.go b/service/task_billing.go index 70d6e62d..61ce1a6f 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -460,7 +460,10 @@ func frozenTaskUsageEnvelope(task *model.Task, taskResult *relaycommon.TaskInfo) taskResult != nil && taskResult.UsageEnvelope != nil && taskResult.UsageEnvelope.Stage == types.TaskUsageSourceProviderResponse && taskResult.UsageEnvelope.Completeness == types.TaskUsageCompletenessComplete { - return types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + candidate := types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + if err := validateTaskUsageEnvelopeForPlan(taskBillingPlan(task), candidate); err == nil { + return candidate + } } return types.CloneTaskUsageEnvelope(frozen) } diff --git a/service/task_billing_h3_test.go b/service/task_billing_h3_test.go index e7818b40..7d56498b 100644 --- a/service/task_billing_h3_test.go +++ b/service/task_billing_h3_test.go @@ -208,6 +208,38 @@ func TestH3TerminalDecisionFailsClosedOnUsageEnvelopeIdentityDrift(t *testing.T) require.Nil(t, decision.Settlement.Effect) } +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 TestH3HistoricalPlanWithoutUsageIdentityKeepsLegacyCompatibility(t *testing.T) { plan := buildServiceH3Plan(t, task_billing_setting.H3BillingInput{ Resolution: "768P", OutputDurationSeconds: 5, diff --git a/service/task_polling.go b/service/task_polling.go index 00bd5234..53f0f4ae 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -44,6 +44,9 @@ type taskUsageProvider interface { 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) } @@ -870,7 +873,7 @@ func applyTaskUsageFacts(adaptor TaskPollingAdaptor, responseBody []byte, taskRe return err } } - if err := taskusage.ValidateEnvelope(contract, envelope, types.TaskUsageProducerKindGoAdapter); err != nil { + if err := taskusage.ValidateEnvelope(contract, envelope, provider.UsageProducerKind()); err != nil { return err } if envelope.Stage != types.TaskUsageSourceProviderResponse { diff --git a/service/task_polling_test.go b/service/task_polling_test.go index 1a262bec..3e6c6f84 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" @@ -49,10 +52,11 @@ type usagePollingResponseAdaptor struct { } type usageFactPollingResponseAdaptor struct { - contract types.TaskUsageContract - envelope *types.TaskUsageEnvelope - err error - calls int + contract types.TaskUsageContract + envelope *types.TaskUsageEnvelope + err error + calls int + producerKind string } type configuredWrapperPollingAdaptor struct{} @@ -122,6 +126,13 @@ func (a *usageFactPollingResponseAdaptor) UsageContract() types.TaskUsageContrac 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 @@ -204,6 +215,35 @@ func TestApplyTaskUsageFactsRejectsNonProviderResponseEnvelope(t *testing.T) { require.Nil(t, taskResult.UsageEnvelope) } +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)) + 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" From ee998ddcdaf68376062625c80a9b3ebcaa4364bf Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 19:33:51 +0800 Subject: [PATCH 06/58] test: pin task plugin producer fixture --- service/task_polling_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/service/task_polling_test.go b/service/task_polling_test.go index 3e6c6f84..9e1a9a47 100644 --- a/service/task_polling_test.go +++ b/service/task_polling_test.go @@ -228,6 +228,7 @@ func TestApplyTaskUsageFactsAcceptsTrustedTaskPluginEnvelope(t *testing.T) { 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) From d6ea95d5c153f888a81a8b4d88aa41403ea44904 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 21:17:11 +0800 Subject: [PATCH 07/58] fix: stabilize task usage evidence recovery --- pkg/taskusage/taskusage.go | 33 ++++++++++++++--- pkg/taskusage/taskusage_test.go | 37 +++++++++++++++++++ .../channel/task/doubao/adaptor_usage_test.go | 5 +++ relay/channel/task/hailuo/adaptor_test.go | 4 ++ service/task_billing.go | 2 +- service/task_billing_h3_test.go | 1 + 6 files changed, 75 insertions(+), 7 deletions(-) diff --git a/pkg/taskusage/taskusage.go b/pkg/taskusage/taskusage.go index aab622a7..1a2c6c67 100644 --- a/pkg/taskusage/taskusage.go +++ b/pkg/taskusage/taskusage.go @@ -15,7 +15,10 @@ const ( SourceIDDoubaoVideo = "doubao_video" ) -var canonicalFieldOrder = []string{ +// 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, @@ -155,7 +158,7 @@ func BuildEnvelope(producerKind string, contract types.TaskUsageContract, stage Completeness: completeness, Usage: cloned, } - envelope.EvidenceDigest = evidenceDigest(stage, presence, completeness, cloned) + envelope.EvidenceDigest = evidenceDigest(contract, stage, presence, completeness, cloned) return envelope, nil } @@ -179,8 +182,11 @@ func ValidateEnvelope(contract types.TaskUsageContract, envelope *types.TaskUsag envelope.SchemaVersion != expected.SchemaVersion || envelope.ContractDigest != expected.ContractDigest || envelope.Presence != expected.Presence || - envelope.Completeness != expected.Completeness || - envelope.EvidenceDigest != expected.EvidenceDigest { + envelope.Completeness != expected.Completeness { + return fmt.Errorf("task usage envelope identity or digest mismatch") + } + if envelope.EvidenceDigest != expected.EvidenceDigest && + envelope.EvidenceDigest != legacyEvidenceDigest(envelope.Stage, envelope.Presence, envelope.Completeness, envelope.Usage) { return fmt.Errorf("task usage envelope identity or digest mismatch") } return nil @@ -302,11 +308,26 @@ func usageValues(usage *types.TaskUsage) map[string]*int64 { return values } -func evidenceDigest(stage, presence, completeness string, usage *types.TaskUsage) string { +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 canonicalFieldOrder { + for _, key := range legacyEvidenceFieldOrder { fmt.Fprintf(&canonical, "%s=%s\n", key, pointerString(values[key])) } return sha256Hex(canonical.String()) diff --git a/pkg/taskusage/taskusage_test.go b/pkg/taskusage/taskusage_test.go index 394f1911..959bbb0b 100644 --- a/pkg/taskusage/taskusage_test.go +++ b/pkg/taskusage/taskusage_test.go @@ -88,6 +88,43 @@ func TestEvidenceDigestIsCanonicalAcrossProducerKindsAndFieldOrder(t *testing.T) 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 TestValidateEnvelopeRejectsIdentityAndDigestTampering(t *testing.T) { zero := int64(0) envelope, err := BuildEnvelope(types.TaskUsageProducerKindGoAdapter, DoubaoVideoContract(), types.TaskUsageSourceProviderResponse, &types.TaskUsage{ diff --git a/relay/channel/task/doubao/adaptor_usage_test.go b/relay/channel/task/doubao/adaptor_usage_test.go index 047a3782..fc2fa6b4 100644 --- a/relay/channel/task/doubao/adaptor_usage_test.go +++ b/relay/channel/task/doubao/adaptor_usage_test.go @@ -60,6 +60,11 @@ func TestParseTaskResultAttachesDoubaoUsageEnvelopeWithoutChangingLegacyTokens(t 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) { diff --git a/relay/channel/task/hailuo/adaptor_test.go b/relay/channel/task/hailuo/adaptor_test.go index e4b4fb18..dc867308 100644 --- a/relay/channel/task/hailuo/adaptor_test.go +++ b/relay/channel/task/hailuo/adaptor_test.go @@ -528,6 +528,10 @@ func TestProduceUsageReturnsAuditableH3Envelope(t *testing.T) { 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 TestExtractTaskUsageIgnoresNonObjectGenericUsage(t *testing.T) { diff --git a/service/task_billing.go b/service/task_billing.go index 61ce1a6f..7e611207 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -425,7 +425,7 @@ func prepareTaskTerminalBillingDecision( return decision } if err := validateTaskUsageEnvelopeForPlan(plan, usageEnvelope); err != nil { - decision.ManualReason = "H3 terminal usage contract requires manual reconciliation: " + err.Error() + decision.ManualReason = "H3 terminal usage requires manual reconciliation: usage contract: " + err.Error() return decision } quote, err := task_billing_setting.QuoteH3Final(plan, usage) diff --git a/service/task_billing_h3_test.go b/service/task_billing_h3_test.go index 7d56498b..01bdcda9 100644 --- a/service/task_billing_h3_test.go +++ b/service/task_billing_h3_test.go @@ -202,6 +202,7 @@ func TestH3TerminalDecisionFailsClosedOnUsageEnvelopeIdentityDrift(t *testing.T) 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:")) require.ErrorContains(t, errors.New(decision.ManualReason), "usage envelope identity") require.NotNil(t, decision.Settlement) require.Zero(t, decision.Settlement.FundingDelta) From 8f9af2d0cecdd5553e614a506aab60f7a8a9fd5c Mon Sep 17 00:00:00 2001 From: CSCITech Date: Thu, 10 Sep 2026 23:39:33 +0800 Subject: [PATCH 08/58] fix: reject invalid first task usage envelope --- service/task_billing.go | 5 ++++- service/task_billing_h3_test.go | 39 +++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/service/task_billing.go b/service/task_billing.go index 7e611207..7b4f9fc0 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -468,7 +468,10 @@ func frozenTaskUsageEnvelope(task *model.Task, taskResult *relaycommon.TaskInfo) return types.CloneTaskUsageEnvelope(frozen) } if taskResult != nil && taskResult.UsageEnvelope != nil { - return types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + candidate := types.CloneTaskUsageEnvelope(taskResult.UsageEnvelope) + if err := validateTaskUsageEnvelopeForPlan(taskBillingPlan(task), candidate); err == nil { + return candidate + } } return nil } diff --git a/service/task_billing_h3_test.go b/service/task_billing_h3_test.go index 01bdcda9..96454c82 100644 --- a/service/task_billing_h3_test.go +++ b/service/task_billing_h3_test.go @@ -203,10 +203,13 @@ func TestH3TerminalDecisionFailsClosedOnUsageEnvelopeIdentityDrift(t *testing.T) 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:")) - require.ErrorContains(t, errors.New(decision.ManualReason), "usage envelope identity") + // 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) } func TestFrozenTaskUsageEnvelopeKeepsMissingUntilReplacementMatchesPlan(t *testing.T) { @@ -241,6 +244,35 @@ func TestFrozenTaskUsageEnvelopeKeepsMissingUntilReplacementMatchesPlan(t *testi 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, @@ -313,9 +345,12 @@ func TestH3TerminalDecisionRejectsNonProviderUsageStage(t *testing.T) { decision := prepareTaskTerminalBillingDecision(context.Background(), nil, task, &relaycommon.TaskInfo{ Status: string(model.TaskStatusSuccess), Usage: usage, UsageEnvelope: envelope, }, constant.ChannelTypeMiniMax) - require.ErrorContains(t, errors.New(decision.ManualReason), "provider_response") + // 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) } func TestH3TerminalDecisionUsesValidatedEnvelopeOverDivergentCompatibilityUsage(t *testing.T) { From 4e0bfbe9b267e51b7b456b64f08d076ae17fb380 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 11 Sep 2026 00:36:55 +0800 Subject: [PATCH 09/58] fix: quarantine invalid task usage tokens --- relay/channel/task/doubao/adaptor.go | 14 +++++++++----- relay/channel/task/doubao/adaptor_usage_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index 8744b36f..5c9b8343 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -602,11 +602,15 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e taskResult.Progress = "100%" taskResult.Url = resTask.Content.VideoURL // 解析 usage 信息用于按倍率计费 - if envelope.Usage != nil && envelope.Usage.CompletionTokens != nil { - taskResult.CompletionTokens = int(*envelope.Usage.CompletionTokens) - } - if envelope.Usage != nil && envelope.Usage.TotalTokens != nil { - taskResult.TotalTokens = int(*envelope.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.TaskUsageCompletenessInvalid && 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 diff --git a/relay/channel/task/doubao/adaptor_usage_test.go b/relay/channel/task/doubao/adaptor_usage_test.go index fc2fa6b4..0c77891e 100644 --- a/relay/channel/task/doubao/adaptor_usage_test.go +++ b/relay/channel/task/doubao/adaptor_usage_test.go @@ -81,6 +81,21 @@ func TestParseTaskResultDoesNotLetNonObjectUsageHideTerminalResult(t *testing.T) 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 TestProduceUsageUnwrapsConfiguredRelayEnvelope(t *testing.T) { envelope, err := (&TaskAdaptor{}).ProduceUsage(types.TaskUsageContext{ Stage: types.TaskUsageSourceProviderResponse, From b97d8ca6e2caa20724efa0750b52a7f88dafa8de Mon Sep 17 00:00:00 2001 From: CSCITech Date: Fri, 11 Sep 2026 05:22:34 +0800 Subject: [PATCH 10/58] feat: streamline MiniMax task settlement operations --- controller/smart_ops_alert.go | 35 ++ controller/smart_ops_alert_test.go | 102 ++++++ model/task_billing_contract_test.go | 319 ++++++++++++++++++ model/task_billing_external_test.go | 185 ++++++++++ router/api-router.go | 1 + router/api_router_test.go | 22 ++ service/manual_task_settlement_test.go | 123 ++++++- service/smart_ops_alert.go | 182 +++++++++- .../src/features/smart-ops/alerts.test.tsx | 55 ++- web/default/src/features/smart-ops/api.ts | 19 ++ .../billing-settlement-evidence.tsx | 130 ++++++- .../components/billing-settlement-table.tsx | 7 +- .../manual-task-settlement-dialog.tsx | 43 +-- .../hooks/use-billing-settlement-selection.ts | 35 +- .../lib/manual-task-settlement.test.ts | 43 +-- .../smart-ops/lib/manual-task-settlement.ts | 8 - web/default/src/features/smart-ops/types.ts | 13 +- web/default/src/i18n/locales/en.json | 15 +- web/default/src/i18n/locales/fr.json | 15 +- web/default/src/i18n/locales/ja.json | 15 +- web/default/src/i18n/locales/ru.json | 15 +- web/default/src/i18n/locales/vi.json | 15 +- web/default/src/i18n/locales/zh.json | 15 +- 23 files changed, 1253 insertions(+), 159 deletions(-) create mode 100644 model/task_billing_contract_test.go create mode 100644 model/task_billing_external_test.go diff --git a/controller/smart_ops_alert.go b/controller/smart_ops_alert.go index e82338f8..df102218 100644 --- a/controller/smart_ops_alert.go +++ b/controller/smart_ops_alert.go @@ -68,6 +68,10 @@ type manualTaskBillingCompletionRequest struct { Note string `json:"note"` } +type manualTaskBillingBatchCompletionRequest struct { + Items []model.BillingSettlementReviewTarget `json:"items"` +} + // ReviewBillingSettlements closes a bounded set of current reconciliation // alerts after the administrator acknowledges them. func ReviewBillingSettlements(c *gin.Context) { @@ -236,6 +240,37 @@ 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 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 + } + 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}) +} + // 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..2fe70743 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,107 @@ func TestBillingSettlementMutationRequestsPreserveExplicitFalse(t *testing.T) { assert.Nil(t, omittedCompletion.ActualQuota) } +func TestCompleteManualTaskBillingSettlementsZeroRejectsInvalidBatch(t *testing.T) { + var request manualTaskBillingBatchCompletionRequest + require.NoError(t, common.Unmarshal([]byte(`{"items":[]}`), &request)) + assert.Empty(t, request.Items) +} + +func TestCompleteManualTaskBillingSettlementsZeroReturnsSuccessAndPartialFailure(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}, + 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":1`) + assert.Contains(t, recorder.Body.String(), `"failed_count":1`) + assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"settlement_id":%d`, ordinarySettlement.ID)) + var settled model.BillingSettlement + require.NoError(t, db.First(&settled, h3Settlement.ID).Error) + assert.Equal(t, model.BillingSettlementStatusApplied, settled.Status) + assert.Equal(t, admin.Id, 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, 1000, storedOwner.Quota) +} + func TestCompleteManualTaskBillingSettlementAuditsExactCompletion(t *testing.T) { gin.SetMode(gin.TestMode) oldDB := model.DB diff --git a/model/task_billing_contract_test.go b/model/task_billing_contract_test.go new file mode 100644 index 00000000..f581b6c8 --- /dev/null +++ b/model/task_billing_contract_test.go @@ -0,0 +1,319 @@ +package model + +import ( + "errors" + "fmt" + "testing" + "time" + + "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, Password: "synthetic-fixture", 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) { + 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("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", true}, + {"postgres_safe", "postgres", "postgres://fixture@127.0.0.1/maxapi_task_billing_test_fixture", true}, + {"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..90ee13f6 --- /dev/null +++ b/model/task_billing_external_test.go @@ -0,0 +1,185 @@ +package model + +import ( + "context" + "database/sql" + "errors" + "flag" + "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 + } + 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 + } + 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 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/router/api-router.go b/router/api-router.go index 9800e1e8..db6b6b2a 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -249,6 +249,7 @@ 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-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..a682cc40 100644 --- a/router/api_router_test.go +++ b/router/api_router_test.go @@ -308,6 +308,28 @@ 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`) } func TestUniversalVerifyRateLimitFollowsUserAcrossIPs(t *testing.T) { diff --git a/service/manual_task_settlement_test.go b/service/manual_task_settlement_test.go index c8d7397e..ecf0d85d 100644 --- a/service/manual_task_settlement_test.go +++ b/service/manual_task_settlement_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "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 +95,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 +140,7 @@ func TestCompleteManualTaskBillingSettlementPreservesExplicitZero(t *testing.T) manual.Revision, 9011, &actualQuota, - "Verified that the provider produced no billable output.", + "", ) require.NoError(t, err) @@ -139,6 +155,111 @@ 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)) +} + +func TestCompleteManualTaskBillingSettlementsZeroReturnsPartialFailures(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.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.Equal(t, 1, result.CompletedCount) + assert.Equal(t, 1, result.FailedCount) + assert.Equal(t, []int64{h3Manual.ID}, result.SettlementIDs) + require.Len(t, result.Failed, 1) + assert.Equal(t, ordinaryManual.ID, result.Failed[0].SettlementID) + assert.Contains(t, result.Failed[0].Message, "MiniMax-H3") +} + +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.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 1c8a4f2d..b0bf8488 100644 --- a/service/smart_ops_alert.go +++ b/service/smart_ops_alert.go @@ -12,6 +12,7 @@ import ( "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/setting/billing_reconciliation_setting" @@ -22,6 +23,9 @@ import ( var ErrInvalidBillingSettlementReconciliationQuery = errors.New("invalid billing settlement reconciliation query") var ErrInvalidBillingSettlementReconciliationReview = errors.New("invalid billing settlement reconciliation review") +const manualTaskBillingDefaultNote = "Administrator-approved manual task usage settlement" +const manualTaskBillingZeroNote = "Administrator-confirmed zero final quota settlement" + const ( smartOpsAlertStatusFiring = "firing" smartOpsAlertStatusResolved = "resolved" @@ -432,6 +436,58 @@ type ManualTaskBillingCompletionResult struct { AlreadyApplied bool `json:"already_applied"` } +type ManualTaskBillingBatchFailure struct { + SettlementID int64 `json:"settlement_id"` + Message string `json:"message"` +} + +type ManualTaskBillingBatchCompletionResult struct { + CompletedCount int `json:"completed_count"` + FailedCount int `json:"failed_count"` + SettlementIDs []int64 `json:"settlement_ids"` + Failed []ManualTaskBillingBatchFailure `json:"failed"` +} + +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 +} + +func taskUsesMiniMaxH3(task *model.Task) bool { + if task == nil { + return false + } + // A persisted effective upstream model is authoritative. Do not let a + // client-facing H3 alias bypass a mapping to a legacy non-H3 protocol. + if upstream := strings.TrimSpace(task.Properties.UpstreamModelName); upstream != "" { + return strings.EqualFold(upstream, constant.TaskModelMiniMaxH3) + } + names := []string{task.Properties.OriginModelName} + if billingContext := task.PrivateData.BillingContext; billingContext != nil { + names = append(names, billingContext.OriginModelName) + } + for _, name := range names { + if strings.EqualFold(strings.TrimSpace(name), constant.TaskModelMiniMaxH3) { + return true + } + } + return false +} + // 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,26 +499,44 @@ 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 - } - note = strings.TrimSpace(common.SanitizePersistedLogContent(common.MaskSensitiveInfo(note))) - if err := validateBillingSettlementReviewNote(note); err != nil { - return ManualTaskBillingCompletionResult{}, err + normalizedNote, noteErr := normalizeManualTaskBillingNote(note) + if noteErr != nil { + return ManualTaskBillingCompletionResult{}, noteErr } + note = normalizedNote original, originalAlreadyResolved, err := model.GetManualTaskBillingSettlement(id, expectedRevision) if err != nil { return ManualTaskBillingCompletionResult{}, err } - if originalAlreadyResolved && - (original.ReconciliationReviewedBy != reviewerID || original.ReconciliationReviewNote != note) { - return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + if originalAlreadyResolved { + if original.ReconciliationReviewedBy != reviewerID { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + // The note field was removed from the UI. Treat a generated note on a + // replay as an omitted value and retain the first durable audit note, + // while still rejecting an explicit attempt to rewrite that note. + if note != original.ReconciliationReviewNote { + if !isGeneratedManualTaskBillingNote(note) || strings.TrimSpace(original.ReconciliationReviewNote) == "" { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } + note = original.ReconciliationReviewNote + } } if *actualQuota < 0 || *actualQuota > original.TaskQuota || original.TaskQuota < 0 || original.TaskQuotaTarget != original.TaskQuota || @@ -480,6 +554,9 @@ func CompleteManualTaskBillingSettlement( } return ManualTaskBillingCompletionResult{}, err } + if requireMiniMaxH3 && !taskUsesMiniMaxH3(task) { + return ManualTaskBillingCompletionResult{}, model.ErrBillingSettlementReviewConflict + } expectedSource := model.BillingSettlementSourceWallet if taskIsSubscription(task) { expectedSource = model.BillingSettlementSourceSubscription @@ -571,6 +648,91 @@ func CompleteManualTaskBillingSettlement( }, 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{}{} + original, _, lookupErr := model.GetManualTaskBillingSettlement(target.ID, target.Revision) + if lookupErr == nil { + task, taskErr := model.GetTaskByID(original.TaskID) + if taskErr != nil || !taskUsesMiniMaxH3(task) { + result.FailedCount++ + result.Failed = append(result.Failed, ManualTaskBillingBatchFailure{ + SettlementID: target.ID, + Message: "only MiniMax-H3 task settlements can use the zero-quota batch action", + }) + continue + } + } else { + result.FailedCount++ + result.Failed = append(result.Failed, ManualTaskBillingBatchFailure{ + SettlementID: target.ID, + Message: manualTaskBillingBatchErrorMessage(lookupErr), + }) + continue + } + actualQuota := int64(0) + if _, err := completeManualTaskBillingSettlement( + target.ID, + target.Revision, + reviewerID, + &actualQuota, + manualTaskBillingZeroNote, + true, + ); err != nil { + result.FailedCount++ + result.Failed = append(result.Failed, ManualTaskBillingBatchFailure{ + SettlementID: target.ID, + Message: manualTaskBillingBatchErrorMessage(err), + }) + continue + } + result.CompletedCount++ + result.SettlementIDs = append(result.SettlementIDs, target.ID) + } + return result, nil +} + +func manualTaskBillingBatchErrorMessage(err error) string { + switch { + case errors.Is(err, model.ErrBillingSettlementReviewConflict), + errors.Is(err, model.ErrBillingSettlementTaskConflict), + errors.Is(err, model.ErrBillingSettlementOperationConflict): + return "record changed or could not be applied safely; refresh and reconcile it" + case errors.Is(err, model.ErrBillingSettlementManualReview): + return "record still requires a manual financial review" + case errors.Is(err, model.ErrTokenQuotaInsufficient): + return "the token quota mirror is inconsistent; repair the token record" + case errors.Is(err, model.ErrSubscriptionRefundClamped): + return "the subscription usage mirror is lower than the refund" + case errors.Is(err, model.ErrSubscriptionSettlementUnbound), + errors.Is(err, model.ErrSubscriptionSettlementPeriodChanged): + return "the subscription reservation is unbound or its period changed" + default: + return "manual task billing settlement failed; inspect the reconciliation record" + } +} + type smartOpsAlertRecipient struct { ID int Email string diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index b3194ed3..f0e75954 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -149,7 +149,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 +158,6 @@ describe('SmartOps active alerts', () => { data: { revision: 2, actual_quota: 0, - note: 'Verified provider evidence and exact usage.', }, config: { skipBusinessError: true, skipErrorHandler: true }, }, @@ -744,6 +742,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 @@ -809,6 +809,26 @@ 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 }) + return { + data: { + success: true, + data: { + completed_count: 1, + failed_count: 1, + settlement_ids: [93], + failed: [ + { + settlement_id: 94, + 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( @@ -829,7 +849,7 @@ describe('SmartOps active alerts', () => { name: 'Select billing reconciliation alert 93', }) .hasAttribute('data-disabled'), - true + false ) assert.equal( screen @@ -840,6 +860,26 @@ describe('SmartOps active alerts', () => { true ) }) + const taskCheckbox = within(view.container).getByRole('checkbox', { + name: 'Select billing reconciliation alert 93', + }) + await view.click(taskCheckbox) + const zeroBatchButton = within(view.container).getByRole('button', { + name: 'Review and close selected (1)', + }) + assert.equal(zeroBatchButton.hasAttribute('disabled'), false) + await view.click(zeroBatchButton) + await waitFor(() => { + assert.deepEqual(writes[0], { + url: '/api/smart-ops/billing-settlements/complete-tasks-zero', + data: { items: [{ id: 93, revision: manualRevision }] }, + }) + assert.ok( + (view.container.textContent ?? '').includes( + 'Settlement #94: record changed or could not be applied safely; refresh and reconcile it' + ) + ) + }) assert.ok(completeButton) await view.click(completeButton) @@ -848,14 +888,13 @@ describe('SmartOps active alerts', () => { ) 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', }) 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) + assert.equal(dialogScreen.queryByLabelText('Audit note'), null) assert.ok( (dialog.textContent ?? '').includes( 'This workflow cannot add a charge above the original reservation.' @@ -882,10 +921,7 @@ describe('SmartOps active alerts', () => { staleDialog.querySelector('#manual-task-actual-quota'), quotaInput ) - assert.equal( - staleDialog.querySelector('#manual-task-audit-note'), - noteInput - ) + assert.equal(staleDialog.querySelector('#manual-task-audit-note'), null) assert.equal( staleDialogScreen .getByRole('button', { name: 'Apply exact settlement' }) @@ -894,6 +930,7 @@ describe('SmartOps active alerts', () => { ) } finally { api.get = originalGet + api.post = originalPost delete htmlElementPrototype.attachEvent delete htmlElementPrototype.detachEvent await view.unmount() diff --git a/web/default/src/features/smart-ops/api.ts b/web/default/src/features/smart-ops/api.ts index 62794406..9918ee5e 100644 --- a/web/default/src/features/smart-ops/api.ts +++ b/web/default/src/features/smart-ops/api.ts @@ -23,6 +23,7 @@ import type { BillingSettlementMutationResponse, BillingSettlementReviewRequest, ManualTaskBillingCompletionRequest, + ManualTaskBillingBatchCompletionData, ChannelPerformanceData, ChannelPerformanceQuery, ChannelPerformanceResponse, @@ -82,6 +83,24 @@ export async function completeManualTaskBillingSettlement( return response.data } +export async function completeManualTaskBillingSettlementsZero( + request: BillingSettlementReviewRequest +): Promise< + BillingSettlementMutationResponse & { + data?: ManualTaskBillingBatchCompletionData + } +> { + const response = await api.post< + BillingSettlementMutationResponse & { + data?: ManualTaskBillingBatchCompletionData + } + >('/api/smart-ops/billing-settlements/complete-tasks-zero', request, { + skipBusinessError: true, + skipErrorHandler: true, + }) + return 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..c3b373fd 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 @@ -36,6 +36,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { completeManualTaskBillingSettlement, + completeManualTaskBillingSettlementsZero, reviewBillingSettlements, updateBillingSettlementBlockingPolicy, } from '../api' @@ -48,6 +49,7 @@ import { import type { BillingSettlementReconciliationData, BillingSettlementReconciliationItem, + ManualTaskBillingBatchFailure, BillingSettlementReviewTarget, } from '../types' import { BillingSettlementTable } from './billing-settlement-table' @@ -71,6 +73,9 @@ export function BillingSettlementEvidence( >(() => new Map()) const [manualTaskItem, setManualTaskItem] = useState(null) + const [batchFailures, setBatchFailures] = useState< + ManualTaskBillingBatchFailure[] + >([]) const reconciliationItems = props.data?.items const currentManualTaskItem = useMemo(() => { if (!manualTaskItem) return null @@ -159,6 +164,7 @@ export function BillingSettlementEvidence( }, onSuccess: async (count: number): Promise => { setSelectedTargets(new Map()) + setBatchFailures([]) toast.success( t('Billing reconciliation alerts closed: {{count}}', { count }) ) @@ -183,8 +189,93 @@ export function BillingSettlementEvidence( }, }) + const zeroSettlementMutation = useMutation({ + mutationKey: ['smart-ops', 'manual-task-billing-zero-batch'], + mutationFn: async (targets: BillingSettlementReviewTarget[]) => { + const response = await completeManualTaskBillingSettlementsZero({ + items: targets, + }) + if (!response.success) { + throw new Error( + response.message || t('Failed to complete manual task billing.') + ) + } + return response.data + }, + onSuccess: (data) => { + setSelectedTargets(new Map()) + const completed = data?.completed_count ?? 0 + const failed = data?.failed_count ?? 0 + const failures = data?.failed ?? [] + setBatchFailures(failures) + toast.success( + failed > 0 + ? t( + 'Completed {{completed}} selected task settlements; {{failed}} remain for review.', + { completed, failed } + ) + : t( + 'Completed selected task settlements with zero final quota: {{count}}.', + { count: completed } + ) + ) + if (failures.length > 0) { + toast.warning( + t('Some settlements remain for review: {{ids}}', { + ids: failures + .map((failure) => `#${failure.settlement_id}`) + .join(', '), + }) + ) + } + }, + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: SMART_OPS_ACTIVE_ALERTS_QUERY_KEY, + }), + queryClient.invalidateQueries({ + queryKey: SMART_OPS_BILLING_RECONCILIATION_QUERY_KEY, + }), + ]) + }, + onError: (error) => { + handleServerError(error, { + fallback: mutationErrorMessage( + error, + t('Failed to complete manual task billing.') + ), + }) + }, + }) + const reviewTargets = (targets: BillingSettlementReviewTarget[]): void => { - if (targets.length > 0 && !reviewMutation.isPending) { + if ( + targets.length === 0 || + reviewMutation.isPending || + zeroSettlementMutation.isPending + ) + return + const selectedItems = (reconciliationItems ?? []).filter((item) => + targets.some( + (target) => target.id === item.id && target.revision === item.revision + ) + ) + const manual = selectedItems.filter( + (item) => item.requires_manual_completion + ) + const ordinary = selectedItems.filter( + (item) => !item.requires_manual_completion + ) + if (manual.length > 0 && ordinary.length > 0) { + toast.error( + t('Select either task settlements or ordinary alerts, not both.') + ) + return + } + if (manual.length > 0) { + zeroSettlementMutation.mutate(targets) + } else { reviewMutation.mutate(targets) } } @@ -194,16 +285,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( @@ -245,8 +333,8 @@ 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 @@ -304,7 +392,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.' + 'Batch-close ordinary alerts after review. Root administrators can select MiniMax-H3 task-finalization alerts for an explicit zero-quota settlement; other task records still require an exact quota.' )}

@@ -393,10 +481,13 @@ export function BillingSettlementEvidence( size='sm' onClick={() => reviewTargets(activeSelectedTargets)} disabled={ - activeSelectedTargets.length === 0 || reviewMutation.isPending + activeSelectedTargets.length === 0 || + reviewMutation.isPending || + zeroSettlementMutation.isPending } > - {reviewMutation.isPending && ( + {(reviewMutation.isPending || + zeroSettlementMutation.isPending) && ( + {batchFailures.length > 0 && ( + + + )} !item.requires_manual_completion + (item) => !item.requires_manual_completion || props.canCompleteManualTask ) const { allSelected, someSelected, isSelected, toggleAll, toggleItem } = useBillingSettlementSelection({ items: props.items, + canSelectManualTask: props.canCompleteManualTask, selectedTargets: props.selectedTargets, onSelectedTargetsChange: props.onSelectedTargetsChange, }) @@ -182,7 +183,9 @@ export function BillingSettlementTable( toggleItem(item, checked === true) } disabled={ - props.reviewPending || item.requires_manual_completion + props.reviewPending || + (item.requires_manual_completion && + !props.canCompleteManualTask) } aria-label={t( 'Select billing reconciliation alert {{id}}', 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..26cd0f50 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 @@ -41,7 +41,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 +54,7 @@ interface ManualTaskSettlementDialogProps { onOpenChange: (open: boolean) => void onSubmit: ( item: BillingSettlementReconciliationItem, - actualQuota: number, - note: string + actualQuota: number ) => void } @@ -69,7 +67,7 @@ export function ManualTaskSettlementDialog( resolver: zodResolver( getManualTaskSettlementSchema(t, props.item?.task_quota ?? 0) ), - defaultValues: { actualQuota: '', note: '' }, + defaultValues: { actualQuota: '' }, mode: 'onChange', }) const canSubmit = @@ -77,7 +75,7 @@ export function ManualTaskSettlementDialog( 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)) } }) @@ -151,39 +149,12 @@ export function ManualTaskSettlementDialog( 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')} - -