diff --git a/backend/internal/application/billing/service.go b/backend/internal/application/billing/service.go index 29319dedd..2371daa25 100644 --- a/backend/internal/application/billing/service.go +++ b/backend/internal/application/billing/service.go @@ -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 { @@ -148,6 +149,7 @@ type UsagePricingInput struct { OutputTokens int64 ReasoningTokens int64 CallCount int64 + DurationBillable bool DurationSeconds int64 LatencyMS int64 ServerSideToolUsage map[string]int64 @@ -203,7 +205,6 @@ type ServiceUsageInput struct { OutputTokens int64 ReasoningTokens int64 CallCount int64 - DurationSeconds int64 } // NativeToolPricingView 描述内置原生工具默认计费价格。 @@ -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 @@ -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 @@ -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 @@ -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, @@ -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 @@ -2109,7 +2141,6 @@ 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 @@ -2117,9 +2148,6 @@ func (s *Service) buildUsageServiceItem(ctx context.Context, input ServiceUsageI 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 @@ -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) diff --git a/backend/internal/application/billing/service_model_identity_test.go b/backend/internal/application/billing/service_model_identity_test.go index 3dbbd67eb..3d221627b 100644 --- a/backend/internal/application/billing/service_model_identity_test.go +++ b/backend/internal/application/billing/service_model_identity_test.go @@ -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 } @@ -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"}}, diff --git a/backend/internal/application/channel/service_model.go b/backend/internal/application/channel/service_model.go index 4ba9ff14d..7a7f08142 100644 --- a/backend/internal/application/channel/service_model.go +++ b/backend/internal/application/channel/service_model.go @@ -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 创建平台模型目录项。 // // 创建模型只负责本地目录与展示元数据。 diff --git a/backend/internal/application/conversation/service.go b/backend/internal/application/conversation/service.go index 7e87b7818..a84bb62e2 100644 --- a/backend/internal/application/conversation/service.go +++ b/backend/internal/application/conversation/service.go @@ -155,6 +155,7 @@ type AttachmentInput struct { Current bool // 是否为本轮用户显式上传的附件 MessageRole string ContextMode string + DurationSeconds int64 // 仅生成视频附件使用。 } // SendMessageInput 定义消息发送请求。 diff --git a/backend/internal/application/conversation/service_billing.go b/backend/internal/application/conversation/service_billing.go index 82572d64c..88cfd6cba 100644 --- a/backend/internal/application/conversation/service_billing.go +++ b/backend/internal/application/conversation/service_billing.go @@ -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" ) @@ -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, @@ -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 消息。 diff --git a/backend/internal/application/conversation/service_file_pipeline.go b/backend/internal/application/conversation/service_file_pipeline.go index df156e7b2..85fa17787 100644 --- a/backend/internal/application/conversation/service_file_pipeline.go +++ b/backend/internal/application/conversation/service_file_pipeline.go @@ -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, @@ -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 { diff --git a/backend/internal/application/conversation/service_media_billing_test.go b/backend/internal/application/conversation/service_media_billing_test.go index 211e22935..2535ec7fc 100644 --- a/backend/internal/application/conversation/service_media_billing_test.go +++ b/backend/internal/application/conversation/service_media_billing_test.go @@ -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 { @@ -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{ diff --git a/backend/internal/application/conversation/service_media_cancel.go b/backend/internal/application/conversation/service_media_cancel.go index e9f335cb8..f925d7cbe 100644 --- a/backend/internal/application/conversation/service_media_cancel.go +++ b/backend/internal/application/conversation/service_media_cancel.go @@ -13,6 +13,8 @@ import ( "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" ) +const defaultXAIVideoDurationSeconds int64 = 6 + type canceledMediaGenerationInput struct { Context context.Context Conversation *model.Conversation @@ -24,10 +26,11 @@ type canceledMediaGenerationInput struct { GenerateInput llm.GenerateInput StartedAt time.Time DurationSeconds int64 + Billable bool MetadataRefreshHint string } -// failedMediaBillingResultInput 描述媒体上游成功后本地处理失败时需要保留的计费信息。 +// failedMediaBillingResultInput 描述媒体上游成功后本地处理失败时需要保留的结果信息。 type failedMediaBillingResultInput struct { UserMessage *model.Message AssistantMessage *model.Message @@ -37,9 +40,10 @@ type failedMediaBillingResultInput struct { StartedAt time.Time DurationSeconds int64 Failure error + Billable bool } -// buildFailedMediaBillingResult 保留上游成功后发生本地处理错误时的真实计费上下文。 +// buildFailedMediaBillingResult 保留上游成功后发生本地处理错误时的真实用量上下文。 func buildFailedMediaBillingResult(input failedMediaBillingResultInput) *SendMessageResult { if input.UserMessage == nil || input.AssistantMessage == nil { return nil @@ -73,7 +77,7 @@ func buildFailedMediaBillingResult(input failedMediaBillingResultInput) *SendMes return &SendMessageResult{ UserMessage: userMessage, AssistantMessage: assistantMessage, - Billable: true, + Billable: input.Billable, UpstreamID: input.Route.UpstreamID, UpstreamName: input.Route.UpstreamName, PlatformModelName: input.Route.PlatformModelName, @@ -159,7 +163,7 @@ func (s *Service) completeCanceledMediaGeneration(input canceledMediaGenerationI UserMessage: *input.UserMessage, AssistantMessage: *input.AssistantMessage, MetadataRefreshHint: input.MetadataRefreshHint, - Billable: true, + Billable: input.Billable, UpstreamID: input.Route.UpstreamID, UpstreamName: input.Route.UpstreamName, PlatformModelName: input.Route.PlatformModelName, @@ -192,14 +196,55 @@ func applyMediaRunUsage(run *model.Run, result *SendMessageResult) { } func mediaDurationSecondsFromOptions(options map[string]interface{}) int64 { - for _, key := range []string{"durationSeconds", "duration_seconds", "duration"} { - if seconds := mediaDurationSecondsFromValue(options[key]); seconds > 0 { + paths := [][]string{ + {"durationSeconds"}, + {"duration_seconds"}, + {"duration"}, + {"videoConfig", "durationSeconds"}, + {"video_config", "duration_seconds"}, + {"generationConfig", "videoConfig", "durationSeconds"}, + {"generation_config", "video_config", "duration_seconds"}, + } + for _, path := range paths { + value, ok := readModelOptionPath(options, path) + if !ok { + continue + } + if seconds := mediaDurationSecondsFromValue(value); seconds > 0 { return seconds } } return 0 } +// withDefaultMediaVideoDuration 仅向明确支持 duration 参数的视频协议补齐产品缺省值。 +// 其他协议仍以其返回的真实媒体时长为准,避免发送未声明的厂商参数。 +func withDefaultMediaVideoDuration(options map[string]interface{}, protocol string) map[string]interface{} { + if mediaDurationSecondsFromOptions(options) > 0 || llm.NormalizeAdapter(protocol) != llm.AdapterXAIVideo { + return options + } + next := make(map[string]interface{}, len(options)+1) + for key, value := range options { + next[key] = value + } + next["duration"] = defaultXAIVideoDurationSeconds + return next +} + +func resolveGeneratedVideoDurations(videos []llm.GeneratedVideo, fallbackSeconds int64) ([]int64, int64) { + durations := make([]int64, len(videos)) + var total int64 + for index, video := range videos { + seconds := positiveSeconds(video.DurationSeconds) + if seconds == 0 { + seconds = positiveSeconds(fallbackSeconds) + } + durations[index] = seconds + total += seconds + } + return durations, total +} + func mediaDurationSecondsFromValue(value interface{}) int64 { switch v := value.(type) { case int: diff --git a/backend/internal/application/conversation/service_media_generation.go b/backend/internal/application/conversation/service_media_generation.go index 83dce0322..b5900496f 100644 --- a/backend/internal/application/conversation/service_media_generation.go +++ b/backend/internal/application/conversation/service_media_generation.go @@ -318,6 +318,7 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( Usage: usage, StartedAt: startedAt, Failure: failure, + Billable: true, }) applyMediaRunUsage(run, result) return result @@ -381,6 +382,7 @@ func (s *Service) StreamMediaImage(ctx context.Context, input MediaImageInput) ( EffectiveOptions: filteredOptions, GenerateInput: generateInput, StartedAt: startedAt, + Billable: true, }) if cancelErr != nil { retErr = cancelErr diff --git a/backend/internal/application/conversation/service_media_video.go b/backend/internal/application/conversation/service_media_video.go index 360f6f414..2676be115 100644 --- a/backend/internal/application/conversation/service_media_video.go +++ b/backend/internal/application/conversation/service_media_video.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" "errors" "fmt" "strings" @@ -242,8 +243,9 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( if llm.NormalizeAdapter(route.Protocol) == llm.AdapterGeminiInteractions { filteredOptions = withGeminiInteractionResponseType(filteredOptions, "video") } + filteredOptions = withDefaultMediaVideoDuration(filteredOptions, route.Protocol) durationSeconds := mediaDurationSecondsFromOptions(filteredOptions) - buildBillableFailure := func(failure error, usage llm.Usage) *SendMessageResult { + buildFailureResult := func(failure error, usage llm.Usage) *SendMessageResult { result := buildFailedMediaBillingResult(failedMediaBillingResultInput{ UserMessage: userMessage, AssistantMessage: assistantMessage, @@ -253,6 +255,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( StartedAt: startedAt, DurationSeconds: durationSeconds, Failure: failure, + Billable: false, }) applyMediaRunUsage(run, result) return result @@ -290,6 +293,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( GenerateInput: generateInput, StartedAt: startedAt, DurationSeconds: durationSeconds, + Billable: false, }) if cancelErr != nil { retErr = cancelErr @@ -307,7 +311,11 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( if output == nil || len(output.GeneratedVideos) == 0 { retErr = ErrUpstreamEmptyResponse _ = s.repo.UpdateMessageState(ctx, assistantMessage.ID, "error", classifyRunErrorCode(retErr), truncateError(messageErrorSummary(retErr), 255)) - return buildBillableFailure(retErr, mediaOutputUsage(output)), retErr + return buildFailureResult(retErr, mediaOutputUsage(output)), retErr + } + videoDurations, generatedDurationSeconds := resolveGeneratedVideoDurations(output.GeneratedVideos, durationSeconds) + if generatedDurationSeconds > 0 { + durationSeconds = generatedDurationSeconds } emitMediaEvent(input.OnEvent, "saving_artifact", "saving video", "video") @@ -318,7 +326,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( data, mimeType, readErr := s.readGeneratedVideo(ctx, video, route.BaseURL, route.APIKey) if readErr != nil { retErr = s.finalizeGeneratedMediaArtifactFailure(ctx, run, assistantMessage.ID, i+1, len(output.GeneratedVideos), readErr) - return buildBillableFailure(retErr, output.Usage), retErr + return buildFailureResult(retErr, output.Usage), retErr } fileName := generatedVideoFileName(route.PlatformModelName, now, i, len(output.GeneratedVideos), mimeType) uploadResult, uploadErr := s.UploadFile(ctx, appupload.UploadFileInput{ @@ -332,7 +340,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( if uploadErr != nil { retErr = uploadErr _ = s.repo.UpdateMessageState(ctx, assistantMessage.ID, "error", classifyRunErrorCode(retErr), truncateError(messageErrorSummary(retErr), 255)) - return buildBillableFailure(uploadErr, output.Usage), uploadErr + return buildFailureResult(uploadErr, output.Usage), uploadErr } file := uploadResult.File uploaded = append(uploaded, file) @@ -348,6 +356,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( SHA256: file.SHA256, StoragePath: file.StoragePath, Status: "active", + MetaJSON: generatedVideoAttachmentMetaJSON(videoDurations[i]), UploadedAt: now, }) } @@ -404,7 +413,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( } if err != nil { retErr = err - return buildBillableFailure(err, output.Usage), err + return buildFailureResult(err, output.Usage), err } assistantMessage.Content = content @@ -416,7 +425,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) ( } assistantMessage.LatencyMS = latencyMS assistantMessage.Status = "success" - assistantMessage.Attachments = string(marshalAttachmentSnapshots(videoAttachmentsFromFiles(uploaded))) + assistantMessage.Attachments = string(marshalAttachmentSnapshots(videoAttachmentsFromFiles(uploaded, videoDurations))) run.InputTokens = usage.InputTokens run.OutputTokens = usage.OutputTokens run.CacheReadTokens = usage.CacheReadTokens @@ -597,9 +606,24 @@ func generatedVideoMarkdown(files []model.FileObject) string { return strings.Join(blocks, "\n\n") } -func videoAttachmentsFromFiles(files []model.FileObject) []AttachmentInput { +func generatedVideoAttachmentMetaJSON(durationSeconds int64) string { + if durationSeconds <= 0 { + return "" + } + payload, err := json.Marshal(map[string]int64{"duration_seconds": durationSeconds}) + if err != nil { + return "" + } + return string(payload) +} + +func videoAttachmentsFromFiles(files []model.FileObject, durations []int64) []AttachmentInput { items := make([]AttachmentInput, 0, len(files)) - for _, file := range files { + for index, file := range files { + durationSeconds := int64(0) + if index < len(durations) { + durationSeconds = positiveSeconds(durations[index]) + } items = append(items, AttachmentInput{ FileObjID: file.ID, FileID: file.FileID, @@ -613,6 +637,7 @@ func videoAttachmentsFromFiles(files []model.FileObject) []AttachmentInput { StoragePath: file.StoragePath, ProcessingStatus: file.ProcessingStatus, ProcessingReady: file.ProcessingReady, + DurationSeconds: durationSeconds, }) } return items diff --git a/backend/internal/application/conversation/service_message_usage_test.go b/backend/internal/application/conversation/service_message_usage_test.go index 44977dcf7..9f1a1643b 100644 --- a/backend/internal/application/conversation/service_message_usage_test.go +++ b/backend/internal/application/conversation/service_message_usage_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" ) @@ -321,14 +322,38 @@ func TestTrimToolFollowUpHistoryRemovesOldCompleteTurns(t *testing.T) { } func TestSendMessageBillingDurationSeconds(t *testing.T) { - if got := sendMessageBillingDurationSeconds(&SendMessageResult{DurationSeconds: 5}, 1200); got != 5 { - t.Fatalf("expected explicit duration seconds to win, got %d", got) + videoResult := &SendMessageResult{ + AssistantMessage: model.Message{ContentType: "video", Status: "success"}, + DurationSeconds: 5, + UpstreamProtocol: llm.AdapterXAIVideo, + Billable: true, } - if got := sendMessageBillingDurationSeconds(&SendMessageResult{}, 1201); got != 2 { - t.Fatalf("expected latency to be rounded up to seconds, got %d", got) + if got := sendMessageBillingDurationSeconds(videoResult); got != 5 { + t.Fatalf("expected explicit duration seconds to win, got %d", got) } - if got := sendMessageBillingDurationSeconds(&SendMessageResult{}, 0); got != 0 { - t.Fatalf("expected empty duration for zero latency, got %d", got) + textResult := &SendMessageResult{ + AssistantMessage: model.Message{ContentType: "video", Status: "success"}, + DurationSeconds: 5, + UpstreamProtocol: llm.AdapterXAIResponses, + Billable: true, + } + if got := sendMessageBillingDurationSeconds(textResult); got != 0 { + t.Fatalf("expected non-video protocol duration to be ignored, got %d", got) + } + if got := sendMessageBillingDurationSeconds(&SendMessageResult{ + AssistantMessage: model.Message{ContentType: "video", Status: "success"}, + UpstreamProtocol: llm.AdapterXAIVideo, + Billable: true, + }); got != 0 { + t.Fatalf("expected missing video duration to remain zero, got %d", got) + } + if got := sendMessageBillingDurationSeconds(&SendMessageResult{ + AssistantMessage: model.Message{ContentType: "video", Status: "error"}, + DurationSeconds: 6, + UpstreamProtocol: llm.AdapterXAIVideo, + Billable: true, + }); got != 0 { + t.Fatalf("expected failed video duration to remain zero, got %d", got) } } @@ -342,4 +367,35 @@ func TestMediaDurationSecondsFromOptions(t *testing.T) { if got := mediaDurationSecondsFromOptions(map[string]interface{}{"duration": "bad"}); got != 0 { t.Fatalf("expected invalid duration to be ignored, got %d", got) } + if got := mediaDurationSecondsFromOptions(map[string]interface{}{ + "generation_config": map[string]interface{}{ + "video_config": map[string]interface{}{"duration_seconds": 7}, + }, + }); got != 7 { + t.Fatalf("expected nested video duration seconds, got %d", got) + } +} + +func TestWithDefaultMediaVideoDurationInjectsOnlySupportedProtocol(t *testing.T) { + xaiOptions := withDefaultMediaVideoDuration(nil, llm.AdapterXAIVideo) + if got := mediaDurationSecondsFromOptions(xaiOptions); got != 6 { + t.Fatalf("expected xAI request default duration, got %d", got) + } + explicit := map[string]interface{}{"duration": 9} + if got := withDefaultMediaVideoDuration(explicit, llm.AdapterXAIVideo); got["duration"] != 9 { + t.Fatalf("explicit duration was overwritten: %#v", got) + } + if got := withDefaultMediaVideoDuration(nil, llm.AdapterGeminiInteractions); got != nil { + t.Fatalf("unsupported duration parameter was injected: %#v", got) + } +} + +func TestResolveGeneratedVideoDurationsSumsEveryArtifact(t *testing.T) { + durations, total := resolveGeneratedVideoDurations([]llm.GeneratedVideo{ + {DurationSeconds: 4}, + {}, + }, 6) + if total != 10 || len(durations) != 2 || durations[0] != 4 || durations[1] != 6 { + t.Fatalf("unexpected generated video durations: %#v total=%d", durations, total) + } } diff --git a/backend/internal/application/conversation/service_share.go b/backend/internal/application/conversation/service_share.go index b87596a28..05b12e0dd 100644 --- a/backend/internal/application/conversation/service_share.go +++ b/backend/internal/application/conversation/service_share.go @@ -66,6 +66,7 @@ type sharedAttachmentSnapshot struct { ProcessingReady bool `json:"processing_ready"` ProcessingErrorCode string `json:"processing_error_code"` ProcessingErrorMessage string `json:"processing_error_message"` + DurationSeconds int64 `json:"duration_seconds"` } // GetConversationShare 查询当前会话最近一次分享状态。 @@ -595,7 +596,7 @@ func (s *Service) cloneSharedMessageAttachments( SHA256: targetFile.SHA256, StoragePath: targetFile.StoragePath, Status: "active", - MetaJSON: "", + MetaJSON: generatedVideoAttachmentMetaJSON(snapshot.DurationSeconds), UploadedAt: now, }) } diff --git a/backend/internal/infra/llm/client.go b/backend/internal/infra/llm/client.go index b121dc8a6..f86e3b1eb 100644 --- a/backend/internal/infra/llm/client.go +++ b/backend/internal/infra/llm/client.go @@ -665,10 +665,49 @@ type GeneratedImage struct { // GeneratedVideo 表示视频生成接口返回的一个视频结果。 type GeneratedVideo struct { - URL string - B64JSON string - MIMEType string - FileName string + URL string + B64JSON string + MIMEType string + FileName string + DurationSeconds int64 +} + +// generatedMediaDurationSeconds 将上游媒体时长统一向上取整为可计费秒数。 +func generatedMediaDurationSeconds(values ...interface{}) int64 { + for _, value := range values { + var seconds float64 + switch typed := value.(type) { + case int: + seconds = float64(typed) + case int64: + seconds = float64(typed) + case float64: + seconds = typed + case float32: + seconds = float64(typed) + case string: + text := strings.TrimSpace(strings.ToLower(typed)) + for _, suffix := range []string{"seconds", "second", "secs", "sec", "s"} { + text = strings.TrimSuffix(text, suffix) + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(text), 64) + if err != nil { + continue + } + seconds = parsed + default: + continue + } + if seconds <= 0 { + continue + } + whole := int64(seconds) + if float64(whole) < seconds { + whole++ + } + return whole + } + return 0 } // ReasoningDelta 定义流式 reasoning 增量。 diff --git a/backend/internal/infra/llm/gemini_interactions.go b/backend/internal/infra/llm/gemini_interactions.go index 07731ad7a..cdfe64329 100644 --- a/backend/internal/infra/llm/gemini_interactions.go +++ b/backend/internal/infra/llm/gemini_interactions.go @@ -1151,9 +1151,17 @@ func walkGeminiInteractionVideos(value interface{}, videos *[]GeneratedVideo) { } func geminiVideoFromMap(item map[string]interface{}) (GeneratedVideo, bool) { + fileData := asMap(item["fileData"]) + if len(fileData) == 0 { + fileData = asMap(item["file_data"]) + } + inlineData := asMap(item["inlineData"]) + if len(inlineData) == 0 { + inlineData = asMap(item["inline_data"]) + } mimeType := strings.TrimSpace(firstString(item, "mime_type", "mimeType")) if mimeType == "" { - if inlineData := asMap(item["inlineData"]); len(inlineData) > 0 { + if len(inlineData) > 0 { mimeType = strings.TrimSpace(firstString(inlineData, "mimeType", "mime_type")) } } @@ -1163,7 +1171,7 @@ func geminiVideoFromMap(item map[string]interface{}) (GeneratedVideo, bool) { url := strings.TrimSpace(firstString(item, "uri", "url", "file_uri", "fileUri")) b64 := strings.TrimSpace(firstString(item, "b64_json", "b64Json", "data")) - if fileData := asMap(item["fileData"]); len(fileData) > 0 { + if len(fileData) > 0 { if url == "" { url = strings.TrimSpace(firstString(fileData, "fileUri", "file_uri", "uri", "url")) } @@ -1171,23 +1179,7 @@ func geminiVideoFromMap(item map[string]interface{}) (GeneratedVideo, bool) { mimeType = strings.TrimSpace(firstString(fileData, "mimeType", "mime_type")) } } - if fileData := asMap(item["file_data"]); len(fileData) > 0 { - if url == "" { - url = strings.TrimSpace(firstString(fileData, "fileUri", "file_uri", "uri", "url")) - } - if mimeType == "" { - mimeType = strings.TrimSpace(firstString(fileData, "mimeType", "mime_type")) - } - } - if inlineData := asMap(item["inlineData"]); len(inlineData) > 0 { - if b64 == "" { - b64 = strings.TrimSpace(firstString(inlineData, "data", "b64_json", "b64Json")) - } - if mimeType == "" { - mimeType = strings.TrimSpace(firstString(inlineData, "mimeType", "mime_type")) - } - } - if inlineData := asMap(item["inline_data"]); len(inlineData) > 0 { + if len(inlineData) > 0 { if b64 == "" { b64 = strings.TrimSpace(firstString(inlineData, "data", "b64_json", "b64Json")) } @@ -1206,6 +1198,13 @@ func geminiVideoFromMap(item map[string]interface{}) (GeneratedVideo, bool) { B64JSON: b64, MIMEType: mimeType, FileName: strings.TrimSpace(firstString(item, "file_name", "fileName", "name")), + DurationSeconds: generatedMediaDurationSeconds( + item["duration_seconds"], + item["durationSeconds"], + item["duration"], + fileData["duration_seconds"], + fileData["durationSeconds"], + ), }, true } diff --git a/backend/internal/infra/llm/gemini_interactions_test.go b/backend/internal/infra/llm/gemini_interactions_test.go index 3f653c3c0..7c885daaf 100644 --- a/backend/internal/infra/llm/gemini_interactions_test.go +++ b/backend/internal/infra/llm/gemini_interactions_test.go @@ -327,9 +327,9 @@ func TestParseGeminiInteractionOutputExtractsVideoURIAndInlineData(t *testing.T) body := []byte(`{ "id": "interaction-1", "output": [ - {"type": "video", "fileData": {"fileUri": "https://example.com/video.mp4", "mimeType": "video/mp4"}}, + {"type": "video", "durationSeconds": 5.2, "fileData": {"fileUri": "https://example.com/video.mp4", "mimeType": "video/mp4"}}, {"type": "video", "file_data": {"file_uri": "https://example.com/video.mp4", "mime_type": "video/mp4"}}, - {"type": "video", "inlineData": {"data": "` + inline + `", "mimeType": "video/webm"}} + {"type": "video", "duration_seconds": 3, "inlineData": {"data": "` + inline + `", "mimeType": "video/webm"}} ], "usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 5} }`) @@ -343,10 +343,10 @@ func TestParseGeminiInteractionOutputExtractsVideoURIAndInlineData(t *testing.T) if got := len(output.GeneratedVideos); got != 2 { t.Fatalf("expected duplicate URI to be deduped, got %d videos: %#v", got, output.GeneratedVideos) } - if output.GeneratedVideos[0].URL != "https://example.com/video.mp4" || output.GeneratedVideos[0].MIMEType != "video/mp4" { + if output.GeneratedVideos[0].URL != "https://example.com/video.mp4" || output.GeneratedVideos[0].MIMEType != "video/mp4" || output.GeneratedVideos[0].DurationSeconds != 6 { t.Fatalf("unexpected URI video: %#v", output.GeneratedVideos[0]) } - if output.GeneratedVideos[1].B64JSON != inline || output.GeneratedVideos[1].MIMEType != "video/webm" { + if output.GeneratedVideos[1].B64JSON != inline || output.GeneratedVideos[1].MIMEType != "video/webm" || output.GeneratedVideos[1].DurationSeconds != 3 { t.Fatalf("unexpected inline video: %#v", output.GeneratedVideos[1]) } if output.Usage.InputTokens != 3 || output.Usage.OutputTokens != 5 { diff --git a/backend/internal/infra/llm/xai_videos.go b/backend/internal/infra/llm/xai_videos.go index 2ab3e14ca..7a1865a8a 100644 --- a/backend/internal/infra/llm/xai_videos.go +++ b/backend/internal/infra/llm/xai_videos.go @@ -86,7 +86,7 @@ func (c *Client) generateXAIVideo(ctx context.Context, route RouteConfig, input if err != nil { return nil, acceptedXAIVideoResponseError(err, upstreamDebugSnapshot(req, debugBody, resp, body)) } - return c.pollXAIVideoResult(requestCtx, route, requestID) + return c.pollXAIVideoResult(requestCtx, route, requestID, generatedMediaDurationSeconds(requestBody["duration"])) } func newXAIMediaRequest(ctx context.Context, method string, requestURL string, payload []byte, route RouteConfig) (*http.Request, error) { @@ -214,7 +214,7 @@ func parseXAIVideoRequestID(body []byte) (string, error) { return requestID, nil } -func (c *Client) pollXAIVideoResult(ctx context.Context, route RouteConfig, requestID string) (*GenerateOutput, error) { +func (c *Client) pollXAIVideoResult(ctx context.Context, route RouteConfig, requestID string, requestedDurationSeconds int64) (*GenerateOutput, error) { requestURL := buildXAIVideoResultURL(route.BaseURL, requestID) if requestURL == "" { return nil, MarkRequestAccepted(fmt.Errorf("invalid xAI video result url")) @@ -239,7 +239,7 @@ func (c *Client) pollXAIVideoResult(ctx context.Context, route RouteConfig, requ return nil, MarkRequestAccepted(parseUpstreamError(resp.StatusCode, body, debug)) } - output, pending, err := parseXAIVideoResult(body, requestID) + output, pending, err := parseXAIVideoResult(body, requestID, requestedDurationSeconds) if err != nil { return nil, acceptedXAIVideoResponseError(err, debug) } @@ -261,7 +261,7 @@ func buildXAIVideoResultURL(baseURL string, requestID string) string { return buildVersionedEndpointURL(baseURL, "v1", "/videos/"+url.PathEscape(id)) } -func parseXAIVideoResult(body []byte, requestID string) (*GenerateOutput, bool, error) { +func parseXAIVideoResult(body []byte, requestID string, requestedDurationSeconds int64) (*GenerateOutput, bool, error) { parsed := make(map[string]interface{}) if err := json.Unmarshal(body, &parsed); err != nil { return nil, false, err @@ -298,14 +298,23 @@ func parseXAIVideoResult(body []byte, requestID string) (*GenerateOutput, bool, if videoURL == "" { return nil, false, fmt.Errorf("xAI video result missing downloadable URL") } + durationSeconds := generatedMediaDurationSeconds( + videoPayload["duration_seconds"], + videoPayload["duration"], + fileOutput["duration_seconds"], + parsed["duration_seconds"], + parsed["duration"], + requestedDurationSeconds, + ) result := &GenerateOutput{ ResponseID: strings.TrimSpace(requestID), ToolCalls: make([]ToolCall, 0), ServerToolCalls: make([]ToolCall, 0), GeneratedVideos: []GeneratedVideo{{ - URL: videoURL, - MIMEType: "video/mp4", - FileName: strings.TrimSpace(getString(fileOutput["filename"])), + URL: videoURL, + MIMEType: "video/mp4", + FileName: strings.TrimSpace(getString(fileOutput["filename"])), + DurationSeconds: durationSeconds, }}, RawJSON: string(body), } diff --git a/backend/internal/infra/llm/xai_videos_test.go b/backend/internal/infra/llm/xai_videos_test.go index 9217bb70a..0489c952a 100644 --- a/backend/internal/infra/llm/xai_videos_test.go +++ b/backend/internal/infra/llm/xai_videos_test.go @@ -131,6 +131,7 @@ func TestGenerateXAIVideoSubmitsAndPolls(t *testing.T) { "status":"done", "video":{ "url":"https://example.com/generated.mp4", + "duration_seconds":6, "respect_moderation":true, "file_output":{"filename":"generated.mp4"} }, @@ -162,7 +163,7 @@ func TestGenerateXAIVideoSubmitsAndPolls(t *testing.T) { t.Fatalf("unexpected xAI video output: %#v", output) } video := output.GeneratedVideos[0] - if video.URL != "https://example.com/generated.mp4" || video.MIMEType != "video/mp4" || video.FileName != "generated.mp4" { + if video.URL != "https://example.com/generated.mp4" || video.MIMEType != "video/mp4" || video.FileName != "generated.mp4" || video.DurationSeconds != 6 { t.Fatalf("unexpected generated video: %#v", video) } if !strings.Contains(output.Usage.RawUsageJSON, `"cost_in_usd_ticks":27`) { @@ -205,7 +206,7 @@ func TestParseXAIVideoResultRejectsModeratedOutput(t *testing.T) { _, _, err := parseXAIVideoResult([]byte(`{ "status":"done", "video":{"url":"https://example.com/blocked.mp4","respect_moderation":false} - }`), "video_req_1") + }`), "video_req_1", 6) if err == nil || !strings.Contains(err.Error(), "content moderation") { t.Fatalf("expected moderation error, got %v", err) } diff --git a/backend/internal/infra/persistence/postgres/conversation/repository.go b/backend/internal/infra/persistence/postgres/conversation/repository.go index 07a312640..b33a8761b 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository.go @@ -3356,6 +3356,17 @@ type messageAttachmentSnapshotRow struct { ProcessingReady bool `gorm:"column:processing_ready"` ProcessingErrorCode string `gorm:"column:processing_error_code"` ProcessingErrorMessage string `gorm:"column:processing_error_message"` + MetaJSON string `gorm:"column:meta_json"` +} + +func attachmentDurationSecondsFromMetaJSON(raw string) int64 { + var metadata struct { + DurationSeconds int64 `json:"duration_seconds"` + } + if json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata) != nil || metadata.DurationSeconds <= 0 { + return 0 + } + return metadata.DurationSeconds } func (r *Repo) hydrateMessageAttachments(ctx context.Context, items []models.Message) error { @@ -3383,6 +3394,7 @@ func (r *Repo) hydrateMessageAttachments(ctx context.Context, items []models.Mes "a.file_name", "a.mime_type", "a.file_size", + "a.meta_json", "fo.detected_mime", "fo.file_category", "fo.processing_status", @@ -3399,7 +3411,7 @@ func (r *Repo) hydrateMessageAttachments(ctx context.Context, items []models.Mes grouped := make(map[uint][]map[string]interface{}, len(rows)) for _, row := range rows { - grouped[row.MessageID] = append(grouped[row.MessageID], map[string]interface{}{ + payload := map[string]interface{}{ "file_id": row.FileID, "kind": row.Kind, "file_name": row.FileName, @@ -3411,7 +3423,11 @@ func (r *Repo) hydrateMessageAttachments(ctx context.Context, items []models.Mes "processing_ready": row.ProcessingReady, "processing_error_code": row.ProcessingErrorCode, "processing_error_message": row.ProcessingErrorMessage, - }) + } + if durationSeconds := attachmentDurationSecondsFromMetaJSON(row.MetaJSON); durationSeconds > 0 { + payload["duration_seconds"] = durationSeconds + } + grouped[row.MessageID] = append(grouped[row.MessageID], payload) } for i := range items { payload := grouped[items[i].ID] diff --git a/backend/internal/infra/persistence/postgres/conversation/repository_test.go b/backend/internal/infra/persistence/postgres/conversation/repository_test.go index 515096689..e680a8845 100644 --- a/backend/internal/infra/persistence/postgres/conversation/repository_test.go +++ b/backend/internal/infra/persistence/postgres/conversation/repository_test.go @@ -22,6 +22,17 @@ func TestTranslateErrorAllowsNil(t *testing.T) { } } +func TestAttachmentDurationSecondsFromMetaJSON(t *testing.T) { + if got := attachmentDurationSecondsFromMetaJSON(`{"duration_seconds":6}`); got != 6 { + t.Fatalf("expected attachment duration 6, got %d", got) + } + for _, raw := range []string{"", `{}`, `{"duration_seconds":0}`, `{"duration_seconds":"6"}`} { + if got := attachmentDurationSecondsFromMetaJSON(raw); got != 0 { + t.Fatalf("expected invalid attachment duration for %q, got %d", raw, got) + } + } +} + func TestCreateContextArtifactsRejectsIncompleteOwnerScope(t *testing.T) { repo := NewRepo(openConversationRepositoryTestDB(t)) valid := domainconversation.ContextArtifact{ diff --git a/backend/internal/transport/http/conversation/handler_media.go b/backend/internal/transport/http/conversation/handler_media.go index 967f1bb93..48d7fd05b 100644 --- a/backend/internal/transport/http/conversation/handler_media.go +++ b/backend/internal/transport/http/conversation/handler_media.go @@ -185,7 +185,7 @@ func (h *Handler) streamMediaTask( h.service.FinishMessageGeneration(clientRunID) return } - _ = flushStreamEvent(streamErrorPayload(err)) + _ = flushStreamEvent(mediaStreamErrorPayload(err, result)) h.service.FinishMessageGeneration(clientRunID) return } @@ -201,12 +201,30 @@ func (h *Handler) streamMediaTask( return } appconversation.ApplyUsageBilling(&result.AssistantMessage, usageLedger) - payload := streamErrorPayload(err) - payload["data"] = toSendMessageResponse(result) + payload := mediaStreamErrorPayload(err, result) _ = flushStreamEvent(payload) h.service.FinishMessageGeneration(clientRunID) return } + if result == nil || !result.Billable { + if releaseErr := h.releaseSendMessageUsageAuthorization(authorization); releaseErr != nil { + _ = flushStreamEvent(billingStreamErrorPayload(releaseErr)) + h.service.FinishMessageGeneration(clientRunID) + return + } + if result != nil && result.AssistantMessage.Status == "canceled" { + payload := streamErrorPayload(appconversation.ErrMessageGenerationCanceled) + payload["data"] = toSendMessageResponse(result) + _ = flushStreamEvent(payload) + } else if result != nil { + _ = flushStreamEvent(map[string]interface{}{ + "type": "completed", + "data": toSendMessageResponse(result), + }) + } + h.service.FinishMessageGeneration(clientRunID) + return + } billingCtx, billingCancel := context.WithTimeout(context.Background(), 10*time.Second) usageLedger, billingErr := h.service.RecordSendMessageBilling( @@ -237,6 +255,15 @@ func (h *Handler) streamMediaTask( h.service.FinishMessageGeneration(clientRunID) } +// mediaStreamErrorPayload 在错误事件中保留已持久化的消息结果,供客户端完成临时消息对账。 +func mediaStreamErrorPayload(err error, result *appconversation.SendMessageResult) map[string]interface{} { + payload := streamErrorPayload(err) + if result != nil { + payload["data"] = toSendMessageResponse(result) + } + return payload +} + // mediaImageBillingInput 构造媒体任务复用消息计费链路所需的上下文。 func mediaImageBillingInput( userID uint, diff --git a/backend/internal/transport/http/conversation/handler_test.go b/backend/internal/transport/http/conversation/handler_test.go index 5cc5c8ac4..edd7c0083 100644 --- a/backend/internal/transport/http/conversation/handler_test.go +++ b/backend/internal/transport/http/conversation/handler_test.go @@ -33,6 +33,17 @@ func TestSafeFileContentTypeDowngradesActiveContent(t *testing.T) { } } +func TestMediaStreamErrorPayloadPreservesPersistedResult(t *testing.T) { + result := &appconversation.SendMessageResult{} + payload := mediaStreamErrorPayload(errors.New("store generated video"), result) + if payload["type"] != "error" { + t.Fatalf("payload type = %#v, want error", payload["type"]) + } + if _, ok := payload["data"]; !ok { + t.Fatalf("media error payload lost persisted result: %#v", payload) + } +} + func TestMessagePageParamsAllowsRestoreWindow(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() diff --git a/frontend/features/admin/components/sections/billing/billing-dialogs.tsx b/frontend/features/admin/components/sections/billing/billing-dialogs.tsx index 99c574fee..fe847ea89 100644 --- a/frontend/features/admin/components/sections/billing/billing-dialogs.tsx +++ b/frontend/features/admin/components/sections/billing/billing-dialogs.tsx @@ -96,7 +96,12 @@ function tieredTiersFromJSON(payload: PricingJSONValue): TieredPricingTierForm[] }); } -function pricingFormFromJSON(current: PricingFormState, raw: string, messages: { root: string; model: string; mode: string; tiered: string }): PricingFormState { +function pricingFormFromJSON( + current: PricingFormState, + raw: string, + durationPricingEnabled: boolean, + messages: { root: string; model: string; mode: string; durationVideoOnly: string; tiered: string }, +): PricingFormState { const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(messages.root); @@ -110,6 +115,9 @@ function pricingFormFromJSON(current: PricingFormState, raw: string, messages: { if (!pricingMode) { throw new Error(messages.mode); } + if (pricingMode === "duration" && !durationPricingEnabled) { + throw new Error(messages.durationVideoOnly); + } const next: PricingFormState = { ...current, pricingMode, @@ -246,6 +254,7 @@ type PricingBillingDialogProps = { open: boolean; saving: boolean; form: PricingFormState | null; + durationPricingEnabled: boolean; setForm: React.Dispatch>; onOpenChange: (open: boolean) => void; onCancel: () => void; @@ -260,6 +269,7 @@ export function PricingBillingDialog({ open, saving, form, + durationPricingEnabled, setForm, onOpenChange, onCancel, @@ -295,10 +305,11 @@ export function PricingBillingDialog({ return; } try { - const nextForm = pricingFormFromJSON(form, value, { + const nextForm = pricingFormFromJSON(form, value, durationPricingEnabled, { root: t("modelPricing.jsonErrors.root"), model: t("modelPricing.jsonErrors.model"), mode: t("modelPricing.jsonErrors.mode"), + durationVideoOnly: t("modelPricing.jsonErrors.durationVideoOnly"), tiered: t("modelPricing.jsonErrors.tiered"), }); setJSONError(""); @@ -306,19 +317,22 @@ export function PricingBillingDialog({ } catch (error) { setJSONError(error instanceof Error ? error.message : t("modelPricing.jsonErrors.invalid")); } - }, [form, setForm, t]); + }, [durationPricingEnabled, form, setForm, t]); const handleJSONPricingModeChange = React.useCallback((value: string) => { if (!form) { return; } const pricingMode = normalizePricingMode(value); + if (pricingMode === "duration" && !durationPricingEnabled) { + return; + } const nextForm = { ...form, pricingMode }; const nextJSON = pricingFormToJSON(nextForm); setForm(nextForm); setJSONDraft(nextJSON); setJSONError(""); - }, [form, setForm]); + }, [durationPricingEnabled, form, setForm]); return ( @@ -372,10 +386,13 @@ export function PricingBillingDialog({ {t("pricingModes.token")} {t("pricingModes.call")} - {t("pricingModes.duration")} + {t("pricingModes.duration")} {t("pricingModes.tiered")} + {!durationPricingEnabled ? ( +

{t("modelPricing.durationVideoOnly")}

+ ) : null}

{t("modelPricing.freeModel")}

@@ -522,7 +539,7 @@ export function PricingBillingDialog({ {t("pricingModes.token")} {t("pricingModes.call")} - {t("pricingModes.duration")} + {t("pricingModes.duration")} {t("pricingModes.tiered")} diff --git a/frontend/features/admin/components/sections/billing/billing-prices.tsx b/frontend/features/admin/components/sections/billing/billing-prices.tsx index 632ad3058..87e201b07 100644 --- a/frontend/features/admin/components/sections/billing/billing-prices.tsx +++ b/frontend/features/admin/components/sections/billing/billing-prices.tsx @@ -413,13 +413,17 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo try { const raw = await file.text(); const validNames = new Set(rows.map((row) => row.platformModelName)); - const parsed = parseModelPricingImportJSON(raw, validNames, { + const videoGenerationNames = new Set( + rows.filter((row) => row.supportsVideoGeneration).map((row) => row.platformModelName), + ); + const parsed = parseModelPricingImportJSON(raw, validNames, videoGenerationNames, { invalidJSON: t("importErrors.invalidJSON"), rootObject: t("importErrors.rootObject"), emptyModelName: t("importErrors.emptyModelName"), duplicateModel: (model) => t("importErrors.duplicateModel", { model }), pricingObject: (model) => t("importErrors.pricingObject", { model }), invalidPricingMode: (model) => t("importErrors.invalidPricingMode", { model }), + durationVideoOnly: (model) => t("importErrors.durationVideoOnly", { model }), invalidNumber: (model, field) => t("importErrors.invalidNumber", { model, field }), invalidTieredPricing: (model, field) => t("importErrors.invalidTieredPricing", { model, field }), invalidTieredPricingJSON: (model) => t("importErrors.invalidTieredPricingJSON", { model }), @@ -695,6 +699,7 @@ export function BillingPricesSection({ models, pricingItems, setPricingItems, lo open={!!editRow && !!form} saving={saving} form={stableForm} + durationPricingEnabled={Boolean(stableEditRow?.supportsVideoGeneration)} setForm={setForm} onOpenChange={(open) => { if (!open && !saving) { diff --git a/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx b/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx index fe971cfed..a12cf0f8c 100644 --- a/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx +++ b/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx @@ -476,7 +476,7 @@ const MODEL_CAPABILITY_PRESETS: CapabilityPreset[] = [ payload: { defaultOptions: { aspect_ratio: "16:9", - duration: 8, + duration: 6, resolution: "720p", }, optionControls: [ diff --git a/frontend/features/admin/model/billing-settings.ts b/frontend/features/admin/model/billing-settings.ts index 60e7815e3..aa7c4f097 100644 --- a/frontend/features/admin/model/billing-settings.ts +++ b/frontend/features/admin/model/billing-settings.ts @@ -5,6 +5,7 @@ import type { } from "@/features/admin/api/billing.types"; import type { AdminLLMModelDTO } from "@/features/admin/api/llm.types"; import type { PatchSettingItem, SettingItem } from "@/shared/api/settings.types"; +import { parseKindsJSON } from "@/shared/model/llm-schema"; export type BillingModelPricingRow = { platformModelName: string; @@ -12,6 +13,7 @@ export type BillingModelPricingRow = { icon: string; pricing: AdminModelPricingDTO | null; isFree: boolean; + supportsVideoGeneration: boolean; }; export type PricingMode = "token" | "call" | "duration" | "tiered"; @@ -74,6 +76,7 @@ export type ModelPricingImportMessages = { duplicateModel: (model: string) => string; pricingObject: (model: string) => string; invalidPricingMode: (model: string) => string; + durationVideoOnly: (model: string) => string; invalidNumber: (model: string, field: string) => string; invalidTieredPricing: (model: string, field: string) => string; invalidTieredPricingJSON: (model: string) => string; @@ -305,6 +308,7 @@ const DEFAULT_IMPORT_MESSAGES: ModelPricingImportMessages = { duplicateModel: (model) => `${model} appears more than once`, pricingObject: (model) => `${model} pricing must be an object`, invalidPricingMode: (model) => `${model}.pricingMode must be token, call, duration, or tiered`, + durationVideoOnly: (model) => `${model}.pricingMode=duration requires the video_gen model capability`, invalidNumber: (model, field) => `${model}.${field} must be a number greater than or equal to 0`, invalidTieredPricing: (model, field) => `${model}.${field} must contain a non-empty tiers array`, invalidTieredPricingJSON: (model) => `${model}.tieredPricingJSON is not valid JSON`, @@ -460,6 +464,7 @@ export function createOptimisticModelPricing(row: BillingModelPricingRow, payloa export function parseModelPricingImportJSON( raw: string, knownPlatformModelNames: Set, + videoGenerationModelNames: Set, messages: ModelPricingImportMessages = DEFAULT_IMPORT_MESSAGES, ): ModelPricingImportParseResult { const errors: string[] = []; @@ -512,6 +517,10 @@ export function parseModelPricingImportJSON( } const entryErrors: string[] = []; const pricingMode = rawEntry.pricingMode; + if (pricingMode === "duration" && !videoGenerationModelNames.has(platformModelName)) { + errors.push(messages.durationVideoOnly(platformModelName)); + continue; + } const tieredPricingJSON = pricingMode === "tiered" ? parseTieredPricingImportValue(rawEntry, platformModelName, entryErrors, messages) : undefined; @@ -561,6 +570,7 @@ export function buildPricingRows(models: AdminLLMModelDTO[], pricingItems: Admin icon: pricing?.modelIcon || model.icon || "", pricing, isFree: pricing?.isFree ?? false, + supportsVideoGeneration: parseKindsJSON(model.kindsJSON).includes("video_gen"), }; }); } diff --git a/frontend/features/chat/components/message/message-attachment.tsx b/frontend/features/chat/components/message/message-attachment.tsx index 7b029e7aa..38cd6760f 100644 --- a/frontend/features/chat/components/message/message-attachment.tsx +++ b/frontend/features/chat/components/message/message-attachment.tsx @@ -29,7 +29,11 @@ function resolveFileExt(name: string): string { } function resolveCardMeta(att: MessageAttachment): string { - return `${resolveFileExt(att.fileName)} · ${formatBytes(att.sizeBytes)}`; + const values = [resolveFileExt(att.fileName), formatBytes(att.sizeBytes)]; + if (att.durationSeconds && att.durationSeconds > 0) { + values.push(`${att.durationSeconds}s`); + } + return values.join(" · "); } // ─── single card ───────────────────────────────────────────────────────────── diff --git a/frontend/features/chat/components/message/message-bot.tsx b/frontend/features/chat/components/message/message-bot.tsx index 4dad356ee..7a7cb56a4 100644 --- a/frontend/features/chat/components/message/message-bot.tsx +++ b/frontend/features/chat/components/message/message-bot.tsx @@ -685,12 +685,20 @@ function MessageInlineVideoPreview({ const resolveErrorMessage = useLocalizedErrorMessage(); const objectURLRef = React.useRef(null); const [state, setState] = React.useState({ status: "loading" }); + const [detectedDurationSeconds, setDetectedDurationSeconds] = React.useState(); const fileID = attachment.fileID; const fileName = attachment.fileName; const mimeType = attachment.mimeType; const detectedMime = attachment.detectedMime; const previewURL = attachment.previewURL; const sizeBytes = attachment.sizeBytes; + const displayDurationSeconds = attachment.durationSeconds ?? detectedDurationSeconds; + + const handleDurationChange = React.useCallback((durationSeconds: number) => { + setDetectedDurationSeconds( + Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.ceil(durationSeconds) : undefined, + ); + }, []); const revokeObjectURL = React.useCallback(() => { if (!objectURLRef.current) { @@ -703,6 +711,7 @@ function MessageInlineVideoPreview({ React.useEffect(() => { let cancelled = false; revokeObjectURL(); + setDetectedDurationSeconds(undefined); if (previewURL) { setState({ @@ -786,14 +795,23 @@ function MessageInlineVideoPreview({ } return ( -
+
+ {displayDurationSeconds && displayDurationSeconds > 0 ? ( + + {displayDurationSeconds}s + + ) : null}
); } diff --git a/frontend/features/chat/model/chat-message-render.ts b/frontend/features/chat/model/chat-message-render.ts index 71783c98d..9b5a74aeb 100644 --- a/frontend/features/chat/model/chat-message-render.ts +++ b/frontend/features/chat/model/chat-message-render.ts @@ -104,6 +104,7 @@ function areAttachmentsEqual( item.detectedMime === nextItem.detectedMime && item.fileCategory === nextItem.fileCategory && item.sizeBytes === nextItem.sizeBytes && + item.durationSeconds === nextItem.durationSeconds && item.kind === nextItem.kind && item.previewURL === nextItem.previewURL && item.processingStatus === nextItem.processingStatus && diff --git a/frontend/features/chat/model/chat-thread.ts b/frontend/features/chat/model/chat-thread.ts index b7fdf5a3a..205c4aded 100644 --- a/frontend/features/chat/model/chat-thread.ts +++ b/frontend/features/chat/model/chat-thread.ts @@ -1,6 +1,14 @@ import type { ChatAreaMessage, MessageAttachment } from "@/features/chat/types/messages"; import type { MessageDTO, UpstreamDebugInfo } from "@/shared/api/conversation.types"; +function parseAttachmentDurationSeconds(value: unknown): number | undefined { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return Math.ceil(parsed); +} + export function parseAttachments(raw: string): MessageAttachment[] { if (!raw) return []; try { @@ -14,6 +22,7 @@ export function parseAttachments(raw: string): MessageAttachment[] { detectedMime: String(item.detected_mime ?? ""), fileCategory: String(item.file_category ?? ""), sizeBytes: Number(item.file_size ?? 0), + durationSeconds: parseAttachmentDurationSeconds(item.duration_seconds), kind: item.kind === "image" ? ("image" as const) : ("file" as const), processingStatus: String(item.processing_status ?? ""), processingReady: Boolean(item.processing_ready), diff --git a/frontend/features/chat/types/messages.ts b/frontend/features/chat/types/messages.ts index b3ae47595..85a9d9edd 100644 --- a/frontend/features/chat/types/messages.ts +++ b/frontend/features/chat/types/messages.ts @@ -7,6 +7,7 @@ export type MessageAttachment = { detectedMime?: string; fileCategory?: string; sizeBytes: number; + durationSeconds?: number; kind: "file" | "image"; previewURL?: string; processingStatus?: string; diff --git a/frontend/i18n/messages/en-US/admin-billing.json b/frontend/i18n/messages/en-US/admin-billing.json index c429a2072..56b08d990 100644 --- a/frontend/i18n/messages/en-US/admin-billing.json +++ b/frontend/i18n/messages/en-US/admin-billing.json @@ -184,6 +184,7 @@ "root": "Model pricing JSON must be an object.", "model": "Platform model name cannot be changed from JSON.", "mode": "pricingMode must be token, call, duration, or tiered.", + "durationVideoOnly": "Per-second pricing is available only for video generation models.", "tiered": "Tiered pricing requires a non-empty tieredPricing.tiers array." }, "officialPricing": "Quick setup", @@ -208,6 +209,7 @@ "cacheWritePerM": "Cache write / 1M", "perCall": "Per call", "perSecond": "Per second", + "durationVideoOnly": "Per-second pricing requires the video generation capability.", "tieredHint": "Tier matching uses raw input token ranges.", "addTier": "Add tier", "tierName": "Tier {index}", @@ -333,6 +335,7 @@ "duplicateModel": "{model} appears more than once", "pricingObject": "{model} pricing must be an object", "invalidPricingMode": "{model}.pricingMode must be token, call, duration, or tiered", + "durationVideoOnly": "{model}.pricingMode=duration requires the video_gen capability", "invalidNumber": "{model}.{field} must be a number greater than or equal to 0", "invalidTieredPricing": "{model}.{field} must contain a non-empty tiers array", "invalidTieredPricingJSON": "{model}.tieredPricingJSON is not valid JSON" diff --git a/frontend/i18n/messages/en-US/chat.json b/frontend/i18n/messages/en-US/chat.json index e9633258a..f1175c2f5 100644 --- a/frontend/i18n/messages/en-US/chat.json +++ b/frontend/i18n/messages/en-US/chat.json @@ -187,6 +187,7 @@ "contextCompressed": "Context was automatically compressed", "scrollToBottom": "Scroll to bottom", "processing": "Processing...", + "videoDuration": "Video duration: {seconds} seconds", "expandUserMessage": "Expand", "collapseUserMessage": "Collapse", "editCreatesBranch": "Saving creates a new branch in this conversation.", diff --git a/frontend/i18n/messages/zh-CN/admin-billing.json b/frontend/i18n/messages/zh-CN/admin-billing.json index dd42918f7..ac902e535 100644 --- a/frontend/i18n/messages/zh-CN/admin-billing.json +++ b/frontend/i18n/messages/zh-CN/admin-billing.json @@ -184,6 +184,7 @@ "root": "模型定价 JSON 必须是对象。", "model": "不能通过 JSON 修改平台模型名。", "mode": "pricingMode 必须是 token、call、duration 或 tiered。", + "durationVideoOnly": "仅视频生成模型可以使用按秒计费。", "tiered": "阶梯计费需要包含非空 tieredPricing.tiers。" }, "officialPricing": "快速配置", @@ -208,6 +209,7 @@ "cacheWritePerM": "缓存(写) / 1M", "perCall": "每次调用", "perSecond": "每秒生成", + "durationVideoOnly": "按秒计费仅适用于具备视频生成能力的模型。", "tieredHint": "阶梯按原始输入区间命中", "addTier": "添加阶梯", "tierName": "第 {index} 档", @@ -333,6 +335,7 @@ "duplicateModel": "{model} 重复出现", "pricingObject": "{model} 的定价配置必须是对象", "invalidPricingMode": "{model}.pricingMode 必须是 token、call、duration 或 tiered", + "durationVideoOnly": "{model}.pricingMode=duration 需要 video_gen 能力", "invalidNumber": "{model}.{field} 必须是大于等于 0 的数字", "invalidTieredPricing": "{model}.{field} 需要包含非空 tiers 数组", "invalidTieredPricingJSON": "{model}.tieredPricingJSON 不是合法 JSON" diff --git a/frontend/i18n/messages/zh-CN/chat.json b/frontend/i18n/messages/zh-CN/chat.json index 733dd293c..9647f9a31 100644 --- a/frontend/i18n/messages/zh-CN/chat.json +++ b/frontend/i18n/messages/zh-CN/chat.json @@ -187,6 +187,7 @@ "contextCompressed": "上下文已自动压缩", "scrollToBottom": "回到底部", "processing": "正在处理…", + "videoDuration": "视频时长:{seconds} 秒", "expandUserMessage": "展开", "collapseUserMessage": "收起", "editCreatesBranch": "保存后会在当前 conversation 内创建新分支。", diff --git a/frontend/shared/components/file-preview/preview-media.tsx b/frontend/shared/components/file-preview/preview-media.tsx index 2ac94cf69..a495c1107 100644 --- a/frontend/shared/components/file-preview/preview-media.tsx +++ b/frontend/shared/components/file-preview/preview-media.tsx @@ -17,6 +17,7 @@ type PreviewMediaProps = { contentType?: string; toolbarContainer?: HTMLElement | null; inline?: boolean; + onDurationChange?: (durationSeconds: number) => void; }; const IMAGE_PREVIEW = { @@ -70,7 +71,15 @@ function resolveAudioLabel(contentType?: string, name?: string): string { return "audio"; } -export function PreviewMedia({ kind, source, alt, contentType, toolbarContainer, inline = false }: PreviewMediaProps) { +export function PreviewMedia({ + kind, + source, + alt, + contentType, + toolbarContainer, + inline = false, + onDurationChange, +}: PreviewMediaProps) { const t = useTranslations("files.previewErrors"); const tPreview = useTranslations("files.preview"); const mediaRef = React.useRef(null); @@ -294,9 +303,11 @@ export function PreviewMedia({ kind, source, alt, contentType, toolbarContainer, }, [kind, playing]); const syncMediaMetrics = React.useCallback((media: HTMLAudioElement | HTMLVideoElement) => { - setDuration(media.duration || 0); + const nextDuration = media.duration || 0; + setDuration(nextDuration); setCurrentTime(media.currentTime || 0); - }, []); + onDurationChange?.(nextDuration); + }, [onDurationChange]); const handleMediaLoadedMetadata = React.useCallback((event: React.SyntheticEvent) => { syncMediaMetrics(event.currentTarget);