Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions backend/internal/application/billing/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ type platformModelIdentityResolver interface {

type modelPricingCatalogProvider interface {
ListActivePlatformModelNames(ctx context.Context) (map[string]struct{}, error)
SupportsVideoGeneration(ctx context.Context, platformModelName string) (bool, error)
}

type nativeToolCatalogProvider interface {
Expand Down Expand Up @@ -148,6 +149,7 @@ type UsagePricingInput struct {
OutputTokens int64
ReasoningTokens int64
CallCount int64
DurationBillable bool
DurationSeconds int64
LatencyMS int64
ServerSideToolUsage map[string]int64
Expand Down Expand Up @@ -203,7 +205,6 @@ type ServiceUsageInput struct {
OutputTokens int64
ReasoningTokens int64
CallCount int64
DurationSeconds int64
}

// NativeToolPricingView 描述内置原生工具默认计费价格。
Expand Down Expand Up @@ -1173,6 +1174,18 @@ func (s *Service) AuthorizeUsage(ctx context.Context, userID uint, platformModel
if pricing.IsFree {
return authorization, nil
}
if normalizePricingMode(pricing.PricingMode) == domainbilling.PricingModeDuration {
if s.modelPricingCatalog == nil {
return nil, ErrModelPricingRequired
}
supported, supportErr := s.modelPricingCatalog.SupportsVideoGeneration(ctx, platformModelName)
if supportErr != nil {
return nil, supportErr
}
if !supported {
return nil, ErrModelPricingRequired
}
}
reservationNanousd, err := s.repo.GetBillingPrepaidAmountNanousd(ctx)
if err != nil {
return nil, err
Expand Down Expand Up @@ -1522,6 +1535,12 @@ func (s *Service) BuildUsageLedger(ctx context.Context, input UsagePricingInput)
// 授权后价格被删除时必须进入待核对流程,不能把已发生的上游用量静默记为 0。
return nil, ErrModelPricingRequired
}
if mode != "self" && !input.ServiceOnly && pricing != nil && !pricing.IsFree && normalizePricingMode(pricing.PricingMode) == domainbilling.PricingModeDuration {
if !input.DurationBillable || input.DurationSeconds <= 0 {
// 请求开始后的模型能力或结果状态发生变化时,宁可进入待核对流程,也不能静默记成零费用。
return nil, ErrModelPricingRequired
}
}

currency := "USD"
var inputNanousdPerMTokens int64
Expand Down Expand Up @@ -1594,12 +1613,12 @@ func (s *Service) BuildUsageLedger(ctx context.Context, input UsagePricingInput)
if callCount <= 0 {
callCount = 1
}
durationSeconds := input.DurationSeconds
if durationSeconds < 0 {
durationSeconds = 0
}
if pricingMode == domainbilling.PricingModeDuration && durationSeconds <= 0 {
durationSeconds = 1
durationSeconds := int64(0)
if input.DurationBillable {
durationSeconds = input.DurationSeconds
if durationSeconds < 0 {
durationSeconds = 0
}
}
var inputBilledNanousd int64
var cacheReadBilledNanousd int64
Expand Down Expand Up @@ -1709,6 +1728,7 @@ func (s *Service) BuildUsageLedger(ctx context.Context, input UsagePricingInput)
"rate_multiplier": billingRateMultiplierValue(rateMultiplier),
"billing_mode": mode,
"pricing_mode": pricingMode,
"duration_billable": input.DurationBillable,
"is_free_model": isFreeModel,
"currency": currency,
"input_nanousd_per_m_tokens": inputNanousdPerMTokens,
Expand Down Expand Up @@ -1951,6 +1971,18 @@ func (s *Service) UpsertModelPricing(ctx context.Context, input ModelPricingInpu
return nil, err
}
pricingMode := normalizePricingMode(input.PricingMode)
if pricingMode == domainbilling.PricingModeDuration {
if s.modelPricingCatalog == nil {
return nil, ErrInvalidModelPricing
}
supported, supportErr := s.modelPricingCatalog.SupportsVideoGeneration(ctx, platformModelName)
if supportErr != nil {
return nil, supportErr
}
if !supported {
return nil, ErrInvalidModelPricing
}
}
var inputNanousdPerMTokens int64
var cacheReadNanousdPerMTokens int64
var cacheWriteNanousdPerMTokens int64
Expand Down Expand Up @@ -2109,17 +2141,13 @@ func (s *Service) buildUsageServiceItem(ctx context.Context, input ServiceUsageI
OutputTokens: clampNonNegative(input.OutputTokens),
ReasoningTokens: clampNonNegative(input.ReasoningTokens),
CallCount: input.CallCount,
DurationSeconds: input.DurationSeconds,
}
if item.ServiceName == "" {
item.ServiceName = item.ServiceCode
}
if item.CallCount <= 0 {
item.CallCount = 1
}
if item.DurationSeconds < 0 {
item.DurationSeconds = 0
}
identity, err := s.resolvePlatformModelIdentity(ctx, item.PlatformModelName)
if err != nil && !errors.Is(err, repository.ErrNotFound) {
return item, err
Expand Down Expand Up @@ -2159,9 +2187,6 @@ func (s *Service) buildUsageServiceItem(ctx context.Context, input ServiceUsageI
item.CallBilledNanousd = item.CallCount * item.CallNanousdPerCall
case domainbilling.PricingModeDuration:
item.DurationNanousdPerSecond = applyRateMultiplier(pricing.DurationNanousdPerSecond, rateMultiplier)
if item.DurationSeconds <= 0 {
item.DurationSeconds = 1
}
item.DurationBilledNanousd = item.DurationSeconds * item.DurationNanousdPerSecond
case domainbilling.PricingModeTiered:
tiers, parseErr := parseTieredPricingTiers(pricing.TieredPricingJSON)
Expand Down
105 changes: 105 additions & 0 deletions backend/internal/application/billing/service_model_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ type modelIdentityResolverStub struct {
identity PlatformModelIdentity
}

type modelPricingCatalogStub struct {
names map[string]struct{}
videoNames map[string]struct{}
}

func (s modelPricingCatalogStub) ListActivePlatformModelNames(context.Context) (map[string]struct{}, error) {
return s.names, nil
}

func (s modelPricingCatalogStub) SupportsVideoGeneration(_ context.Context, platformModelName string) (bool, error) {
_, ok := s.videoNames[platformModelName]
return ok, nil
}

func (s modelIdentityResolverStub) ResolvePlatformModelIdentity(context.Context, string) (PlatformModelIdentity, error) {
return s.identity, nil
}
Expand All @@ -29,6 +43,97 @@ func TestUpstreamUsageSnapshotReturnsEmptyObjectWhenRawUsageIsMissing(t *testing
}
}

func TestUpsertModelPricingRestrictsDurationModeToVideoModels(t *testing.T) {
repo := &billingRepositoryStub{}
service := NewService(repo)
service.SetModelPricingCatalogProvider(modelPricingCatalogStub{
names: map[string]struct{}{"chat-model": {}, "video-model": {}},
videoNames: map[string]struct{}{
"video-model": {},
},
})

_, err := service.UpsertModelPricing(t.Context(), ModelPricingInput{
PlatformModelName: "chat-model",
PricingMode: domainbilling.PricingModeDuration,
DurationNanousdPerSecond: 1,
})
if !errors.Is(err, ErrInvalidModelPricing) {
t.Fatalf("expected duration pricing to reject chat model, got %v", err)
}

view, err := service.UpsertModelPricing(t.Context(), ModelPricingInput{
PlatformModelName: "video-model",
PricingMode: domainbilling.PricingModeDuration,
DurationNanousdPerSecond: 2,
})
if err != nil {
t.Fatalf("expected duration pricing for video model: %v", err)
}
if view.PricingMode != domainbilling.PricingModeDuration || view.DurationNanousdPerSecond != 2 {
t.Fatalf("unexpected duration pricing: %#v", view)
}
}

func TestBuildUsageLedgerBillsDurationOnlyWhenExplicitlyBillable(t *testing.T) {
repo := &billingRepositoryStub{
mode: "usage",
pricing: &domainbilling.ModelPricing{
PlatformModelName: "video-model",
Currency: "USD",
PricingMode: domainbilling.PricingModeDuration,
DurationNanousdPerSecond: 3,
},
}
service := NewService(repo)

_, err := service.BuildUsageLedger(t.Context(), UsagePricingInput{
UserID: 1,
PlatformModelName: "video-model",
DurationSeconds: 6,
})
if !errors.Is(err, ErrModelPricingRequired) {
t.Fatalf("build non-video duration ledger error = %v, want ErrModelPricingRequired", err)
}

video, err := service.BuildUsageLedger(t.Context(), UsagePricingInput{
UserID: 1,
PlatformModelName: "video-model",
DurationBillable: true,
DurationSeconds: 6,
})
if err != nil {
t.Fatalf("build video duration ledger: %v", err)
}
if video.DurationSeconds != 6 || video.BilledNanousd != 18 {
t.Fatalf("unexpected video duration billing: %#v", video)
}
}

func TestAuthorizeUsageRejectsLegacyDurationPricingForNonVideoModel(t *testing.T) {
repo := &billingRepositoryStub{
mode: "usage",
pricing: &domainbilling.ModelPricing{
PlatformModelName: "legacy-chat-model",
PricingMode: domainbilling.PricingModeDuration,
DurationNanousdPerSecond: 3,
},
}
service := NewService(repo)
service.SetModelPricingCatalogProvider(modelPricingCatalogStub{
names: map[string]struct{}{"legacy-chat-model": {}},
videoNames: map[string]struct{}{},
})

_, err := service.AuthorizeUsage(t.Context(), 1, "legacy-chat-model", "run_legacy_duration")
if !errors.Is(err, ErrModelPricingRequired) {
t.Fatalf("AuthorizeUsage() error = %v, want ErrModelPricingRequired", err)
}
if repo.reservationRequest != nil {
t.Fatalf("legacy duration pricing reserved usage before rejection: %#v", repo.reservationRequest)
}
}

func TestUpdatePlanRejectsUnknownPermissionGroup(t *testing.T) {
repo := &billingRepositoryStub{
plans: []domainbilling.Plan{{ID: 1, Code: "pro", Name: "Pro"}},
Expand Down
19 changes: 19 additions & 0 deletions backend/internal/application/channel/service_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,25 @@ func (s *Service) ListActivePlatformModelNames(ctx context.Context) (map[string]
return keys, nil
}

// SupportsVideoGeneration 返回平台模型是否具有真实可路由的视频生成能力。
func (s *Service) SupportsVideoGeneration(ctx context.Context, platformModelName string) (bool, error) {
name, err := normalizePlatformModelName(platformModelName)
if err != nil {
return false, nil
}
items, err := s.listAllActiveModelRows(ctx)
if err != nil {
return false, err
}
for _, item := range items {
if item.ActiveSourceCount <= 0 || strings.TrimSpace(item.PlatformModelName) != name {
continue
}
return hasModelKind(parseKinds(item.KindsJSON), modelKindVideoGen), nil
}
return false, nil
}

// CreateModel 创建平台模型目录项。
//
// 创建模型只负责本地目录与展示元数据。
Expand Down
1 change: 1 addition & 0 deletions backend/internal/application/conversation/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ type AttachmentInput struct {
Current bool // 是否为本轮用户显式上传的附件
MessageRole string
ContextMode string
DurationSeconds int64 // 仅生成视频附件使用。
}

// SendMessageInput 定义消息发送请求。
Expand Down
21 changes: 14 additions & 7 deletions backend/internal/application/conversation/service_billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
appbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/billing"
domainbilling "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/billing"
model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
)

Expand Down Expand Up @@ -304,7 +305,8 @@ func (s *Service) buildSendMessageUsageLedger(ctx context.Context, input SendMes
OutputTokens: result.AssistantMessage.OutputTokens,
ReasoningTokens: result.AssistantMessage.ReasoningTokens,
CallCount: 1,
DurationSeconds: sendMessageBillingDurationSeconds(result, latencyMS),
DurationBillable: sendMessageResultIsVideoGeneration(result),
DurationSeconds: sendMessageBillingDurationSeconds(result),
LatencyMS: latencyMS,
ServerSideToolUsage: result.ServerSideToolUsage,
RawUsageJSON: result.RawUsageJSON,
Expand Down Expand Up @@ -342,14 +344,19 @@ func sendMessageBillingCacheWriteTokens(result *SendMessageResult) int64 {
return result.UserMessage.CacheWriteTokens
}

func sendMessageBillingDurationSeconds(result *SendMessageResult, latencyMS int64) int64 {
if result != nil && result.DurationSeconds > 0 {
return result.DurationSeconds
}
if latencyMS <= 0 {
func sendMessageResultIsVideoGeneration(result *SendMessageResult) bool {
return result != nil &&
result.Billable &&
strings.EqualFold(strings.TrimSpace(result.AssistantMessage.Status), "success") &&
strings.EqualFold(strings.TrimSpace(result.AssistantMessage.ContentType), "video") &&
llm.IsVideoGenerationAdapter(result.UpstreamProtocol)
}

func sendMessageBillingDurationSeconds(result *SendMessageResult) int64 {
if !sendMessageResultIsVideoGeneration(result) || result.DurationSeconds <= 0 {
return 0
}
return (latencyMS + 999) / 1000
return result.DurationSeconds
}

// sendMessageResultUsesAssistantSideInput 判断 prompt-side usage 是否归属 assistant 消息。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ func canUseAttachmentFullContext(att AttachmentInput, cfg config.Config) bool {
}

func buildFileAttachmentSnapshot(att AttachmentInput) map[string]interface{} {
return map[string]interface{}{
payload := map[string]interface{}{
"file_id": att.FileID,
"kind": att.Kind,
"file_name": att.FileName,
Expand All @@ -374,6 +374,10 @@ func buildFileAttachmentSnapshot(att AttachmentInput) map[string]interface{} {
"processing_error_code": att.ProcessingErrorCode,
"processing_error_message": att.ProcessingErrorMessage,
}
if att.DurationSeconds > 0 {
payload["duration_seconds"] = att.DurationSeconds
}
return payload
}

func marshalAttachmentSnapshots(items []AttachmentInput) string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestBuildFailedMediaBillingResultPreservesUpstreamUsage(t *testing.T) {
},
StartedAt: time.Now().Add(-time.Second),
Failure: errors.New("store generated artifact"),
Billable: true,
})

if result == nil || !result.Billable {
Expand All @@ -51,6 +52,20 @@ func TestBuildFailedMediaBillingResultPreservesUpstreamUsage(t *testing.T) {
}
}

func TestBuildFailedMediaBillingResultCanRemainNonBillable(t *testing.T) {
result := buildFailedMediaBillingResult(failedMediaBillingResultInput{
UserMessage: &model.Message{ID: 1},
AssistantMessage: &model.Message{ID: 2, ContentType: "video"},
DurationSeconds: 6,
Failure: errors.New("store generated video"),
Billable: false,
})

if result == nil || result.Billable {
t.Fatalf("result = %+v, want non-billable failed video result", result)
}
}

func TestBuildFailedMediaBillingResultKeepsRetryInputOnAssistant(t *testing.T) {
sourceMessageID := uint(9)
result := buildFailedMediaBillingResult(failedMediaBillingResultInput{
Expand Down
Loading
Loading