From 531019b368711b325e8623381ec2d2da47b714d1 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 18 Sep 2026 23:26:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(*):=20=E6=A8=A1=E5=9E=8B=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E4=B8=8E=E6=B8=A0=E9=81=93=E7=9B=AE=E5=BD=95=20-=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=A7=86=E9=A2=91=20Token=20=E6=8A=A5=E4=BB=B7?= =?UTF-8?q?=E7=BB=93=E7=AE=97=E5=92=8C=E7=A8=B3=E5=AE=9A=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/admin.go | 4 +- .../app/all_video_token_integration_test.go | 86 +++++++ backend/internal/app/analytics.go | 62 ++++- .../internal/app/channel_model_label_test.go | 86 +++++++ backend/internal/app/channel_models.go | 41 ++- backend/internal/app/finance.go | 150 +++-------- .../app/finance_token_arithmetic_test.go | 73 ++++++ backend/internal/app/finance_token_test.go | 16 +- backend/internal/app/logical_model_quote.go | 105 ++++++-- backend/internal/app/logical_models.go | 7 +- backend/internal/app/logical_models_test.go | 4 +- backend/internal/app/model_catalog.go | 4 + backend/internal/app/model_router.go | 3 + backend/internal/app/price_validation.go | 22 +- backend/internal/app/price_validation_test.go | 10 +- backend/internal/app/task_creation.go | 4 +- .../app/video_logical_billing_test.go | 129 ++++++++++ backend/internal/app/video_token_billing.go | 241 ++++++++++++++++++ .../internal/app/video_token_billing_test.go | 67 +++++ .../internal/app/video_token_quote_test.go | 221 ++++++++++++++++ backend/internal/app/video_token_tier_test.go | 123 +++++++++ backend/internal/app/video_usage_test.go | 90 +++++++ .../channel_model_description_test.go | 40 +++ .../database/channel_model_label_test.go | 40 +++ backend/internal/database/migrations.go | 30 ++- backend/internal/database/migrations_test.go | 4 +- .../database/migrations_video_token_test.go | 44 ++++ backend/internal/handler/model_catalog.go | 17 ++ backend/internal/handler/openapi.yaml | 241 ++++++++++++++++++ backend/internal/kernel/token_billing.go | 41 +++ backend/internal/kernel/token_billing_test.go | 90 +++++++ backend/internal/model/models_channel.go | 2 + backend/internal/model/models_finance.go | 43 ++-- backend/internal/repository/finance.go | 99 ++++--- .../finance_token_arithmetic_test.go | 94 +++++++ .../repository/finance_video_formula_test.go | 183 +++++++++++++ .../repository/finance_video_usage_test.go | 55 ++++ backend/internal/service/aliases_types.go | 3 + .../docs/backend/video-token-billing.mdx | 65 +++++ web/src/components/model-logo.tsx | 7 +- web/src/components/model-picker.tsx | 105 ++++---- web/src/lib/model-picker-groups.ts | 64 +++++ web/src/lib/model-pricing.ts | 45 +++- web/src/lib/model-protocols.ts | 4 +- .../components/channel-model-editor-form.ts | 11 +- .../admin/components/channel-model-editor.tsx | 28 +- .../components/channel-model-manager.tsx | 10 +- .../channel-model-price-tier-fields.tsx | 18 +- .../channel-model-price-tier-form.ts | 21 ++ web/src/services/api/logical-models.ts | 27 ++ web/src/services/api/wallet.ts | 6 + web/test/channel-model-editor-form.test.ts | 28 +- .../channel-model-price-tier-form.test.ts | 21 +- web/test/model-picker-groups.test.ts | 93 +++++++ web/test/model-pricing.test.ts | 38 ++- web/test/model-protocols.test.ts | 14 +- web/test/model-quote.test.ts | 66 +++++ 57 files changed, 2904 insertions(+), 341 deletions(-) create mode 100644 backend/internal/app/all_video_token_integration_test.go create mode 100644 backend/internal/app/channel_model_label_test.go create mode 100644 backend/internal/app/finance_token_arithmetic_test.go create mode 100644 backend/internal/app/video_logical_billing_test.go create mode 100644 backend/internal/app/video_token_billing.go create mode 100644 backend/internal/app/video_token_billing_test.go create mode 100644 backend/internal/app/video_token_quote_test.go create mode 100644 backend/internal/app/video_token_tier_test.go create mode 100644 backend/internal/app/video_usage_test.go create mode 100644 backend/internal/database/channel_model_description_test.go create mode 100644 backend/internal/database/channel_model_label_test.go create mode 100644 backend/internal/database/migrations_video_token_test.go create mode 100644 backend/internal/kernel/token_billing.go create mode 100644 backend/internal/kernel/token_billing_test.go create mode 100644 backend/internal/repository/finance_token_arithmetic_test.go create mode 100644 backend/internal/repository/finance_video_formula_test.go create mode 100644 backend/internal/repository/finance_video_usage_test.go create mode 100644 docs/content/docs/backend/video-token-billing.mdx create mode 100644 web/src/lib/model-picker-groups.ts create mode 100644 web/test/model-picker-groups.test.ts create mode 100644 web/test/model-quote.test.ts diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 36065cd03..aeecbd7e5 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -124,6 +124,8 @@ type PublicModelChannel struct { type PublicChannelModelPrice struct { Model string `json:"model"` DisplayName string `json:"displayName"` + ChannelLabel string `json:"channelLabel"` + Description string `json:"description"` Icon string `json:"icon"` Capability string `json:"capability"` Protocol model.ChannelInterfaceType `json:"protocol"` @@ -949,7 +951,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model capabilityConfig = normalized } } - modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) + modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, ChannelLabel: item.ChannelLabel, Description: item.Description, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) } } if len(models) == 0 { diff --git a/backend/internal/app/all_video_token_integration_test.go b/backend/internal/app/all_video_token_integration_test.go new file mode 100644 index 000000000..4bbad7016 --- /dev/null +++ b/backend/internal/app/all_video_token_integration_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestAllVideoProtocolsQuoteReserveAndSettleWithoutProviderUsage(t *testing.T) { + for _, protocol := range []model.ChannelInterfaceType{"newapi-channel-2", "newapi-video", "custom-video"} { + t.Run(string(protocol), func(t *testing.T) { + svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t) + if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("protocol", protocol).Error; err != nil { + t.Fatal(err) + } + intent := ModelRequestIntent{Capability: "video", Options: map[string]any{"vquality": "720p", "size": "16:9", "videoSeconds": 5}} + quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent}) + if err != nil { + t.Fatal(err) + } + assertVideoTokenQuote(t, quote, 1_900_800) + input := quoteInput(intent, channelModel.ModelKey) + input["config"].(map[string]any)["channelId"] = channel.ID + input, err = svc.resolveSystemChannelModelSelection(input, "canvas_video", "") + if err != nil { + t.Fatal(err) + } + order, err := svc.taskBillingOrder("quote-user", &model.Task{ID: "generic-video", Type: "canvas_video"}, input) + if err != nil { + t.Fatal(err) + } + if order.VideoFormulaTokens != 108_000 || order.AmountMicrocredits != quote.AmountMicrocredits { + t.Fatalf("quote/order disagree: %#v / %#v", quote, order) + } + if err := svc.repo.ReserveBillingOrder(order); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := svc.SettleBilling(order.ID, "completed-video"); err != nil { + t.Fatal(err) + } + } + settled, err := svc.repo.BillingOrder(order.ID) + if err != nil { + t.Fatal(err) + } + if settled.Status != model.BillingStatusSettled || settled.UsageSource != "video_formula" || settled.UsageAvailable || settled.OutputTokens != 108_000 || settled.ActualAmountMicrocredits != 1_728_000 || settled.RefundedAmountMicrocredits != 172_800 { + t.Fatalf("wrong formula settlement: %#v", settled) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "quote-user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 10_000_000-1_728_000 || account.ReservedMicrocredits != 250_000 { + t.Fatalf("incorrect balance after idempotent settlement: %#v", account) + } + }) + } +} + +func TestGenericVideoTokenDimensions(t *testing.T) { + for _, test := range []struct { + resolution, size string + width, height int64 + }{ + {"1440p", "16:9", 2560, 1440}, + {"2K", "9:16", 1440, 2560}, + {"960p", "4:3", 1280, 960}, + {"768p竖", "9:16", 768, 1366}, + {"720p", "2:1", 1440, 720}, + {"", "1280x720", 1280, 720}, + {"", "", 1280, 720}, + } { + t.Run(test.resolution+"/"+test.size, func(t *testing.T) { + got := estimateArkVideoTokens(map[string]any{"config": map[string]any{ + "model": "generic-video", "vquality": test.resolution, "size": test.size, "videoSeconds": 5, + }}) + if got.Err != nil || got.Video.OutputWidth != test.width || got.Video.OutputHeight != test.height { + t.Fatalf("dimensions=%#v error=%v", got.Video, got.Err) + } + }) + } +} diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index 7eab218d5..cb24aae7e 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -1035,8 +1035,12 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str log.Error = errorMessage } } + arkVideo := log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") usage, _ := payload["usage"].(map[string]any) - if usage != nil { + if log.Capability == "video" { + log.InputTokens, log.CachedTokens = 0, 0 + log.OutputTokens, log.UsageAvailable = videoCompletionTokens(payload, arkVideo) + } else if usage != nil { inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens") outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens") if inputAvailable { @@ -1048,14 +1052,6 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str if inputAvailable || outputAvailable { log.UsageAvailable = true } - // 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量, - // 兼容只返回 total_tokens 的同协议中转实现。 - if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") { - if log.OutputTokens == 0 { - log.OutputTokens = firstInt64(usage, "total_tokens") - } - log.UsageAvailable = log.OutputTokens > 0 - } if details, ok := usage["input_tokens_details"].(map[string]any); ok { log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens") } @@ -1066,7 +1062,7 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str log.CachedTokens = firstInt64(usage, "cached_tokens", "cache_read_input_tokens", "prompt_cache_hit_tokens") } } - if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok { + if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok && log.Capability != "video" { inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount") outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount") if inputAvailable { @@ -1101,12 +1097,40 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str } } +func videoCompletionTokens(payload map[string]any, arkProtocol bool) (int64, bool) { + if status, exists := payload["status"]; exists { + text, ok := status.(string) + status := strings.ToLower(strings.TrimSpace(text)) + if !ok || (status != "succeeded" && (arkProtocol || (status != "completed" && status != "success"))) { + return 0, false + } + } + usage, _ := payload["usage"].(map[string]any) + value, exists := usage["completion_tokens"] + if !exists && !arkProtocol { + value, exists = usage["output_tokens"] + } + if !exists { + value = usage["total_tokens"] + } + // completion_tokens 一旦返回就是唯一结算依据,非法值不能用 total_tokens 掩盖。 + // JSON Number 保留整数精度,避免浮点取整把小数或超出 int64 的值变成可计费用量。 + number, ok := value.(json.Number) + if !ok { + return 0, false + } + tokens, err := number.Int64() + if err != nil || tokens <= 0 { + return 0, false + } + return tokens, true +} + func providerResponsePayloads(responseBody []byte) []map[string]any { if len(responseBody) == 0 { return nil } - var payload map[string]any - if json.Unmarshal(responseBody, &payload) == nil { + if payload, ok := decodeProviderResponsePayload(responseBody); ok { return []map[string]any{payload} } @@ -1121,8 +1145,7 @@ func providerResponsePayloads(responseBody []byte) []map[string]any { if raw == "" || raw == "[DONE]" { return } - var event map[string]any - if json.Unmarshal([]byte(raw), &event) == nil { + if event, ok := decodeProviderResponsePayload([]byte(raw)); ok { result = append(result, event) } } @@ -1140,6 +1163,17 @@ func providerResponsePayloads(responseBody []byte) []map[string]any { return result } +func decodeProviderResponsePayload(data []byte) (map[string]any, bool) { + if !json.Valid(data) { + return nil, false + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var payload map[string]any + err := decoder.Decode(&payload) + return payload, err == nil && payload != nil +} + func providerRequestIDFromPath(path string) string { parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/") for index := len(parts) - 1; index >= 0; index-- { diff --git a/backend/internal/app/channel_model_label_test.go b/backend/internal/app/channel_model_label_test.go new file mode 100644 index 000000000..63fa7ee84 --- /dev/null +++ b/backend/internal/app/channel_model_label_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestChannelModelLabelSaveAndCatalogPreserveChannelIdentity(t *testing.T) { + svc, db := newChannelModelTestService(t) + if err := db.AutoMigrate(&model.SystemSetting{}); err != nil { + t.Fatal(err) + } + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + enabled := true + request := ChannelModelRequest{ + ModelKey: "gpt-test", ProviderModelKey: "upstream-text", DisplayName: "GPT Test", Icon: "OpenAI", + ChannelLabel: " 优惠渠道-993 ", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), + Description: " 适合分镜脚本,请先确认输入要求。 ", + CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "gpt-test"), + PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", UnitPriceMicrocredits: 300000, PriceConfigured: true, Enabled: &enabled}}, + Enabled: &enabled, + } + var firstID string + for _, id := range []string{"channel-a", "channel-b"} { + channel := model.ModelChannel{ID: id, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.invalid/v1", APIKey: "synthetic", ModelsJSON: `[]`} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + saved, err := svc.SaveAdminChannelModel(admin, id, "", request) + if err != nil { + t.Fatal(err) + } + var stored model.ChannelModel + if err := db.First(&stored, "id = ?", saved.ID).Error; err != nil { + t.Fatal(err) + } + if stored.ChannelLabel != "优惠渠道-993" || stored.ModelKey != "gpt-test" || stored.ProviderModelKey != "upstream-text" || stored.ChannelID != id { + t.Fatalf("stored channel model: %#v", stored) + } + legacy := publicChannel(channel, false, []model.ChannelModel{*saved}) + if stored.Description != "适合分镜脚本,请先确认输入要求。" || legacy.ModelCosts[0].Description != stored.Description { + t.Fatal("model description was not persisted or projected") + } + if legacy.ModelCosts[0].ChannelLabel != "优惠渠道-993" { + t.Fatal("session projection lost channel label") + } + if id == "channel-a" { + firstID = saved.ID + } + } + catalog, err := svc.ModelCatalog(nil) + if err != nil || len(catalog.Channels) != 2 { + t.Fatalf("catalog: %#v, %v", catalog, err) + } + for _, channel := range catalog.Channels { + if len(channel.Models) == 1 && channel.Models[0].Description != "适合分镜脚本,请先确认输入要求。" { + t.Fatal("catalog lost model description") + } + if len(channel.Models) != 1 || channel.Models[0].ChannelLabel != "优惠渠道-993" || !channel.Models[0].Available || channel.Models[0].PriceTiers[0].UnitPriceMicrocredits != 300000 { + t.Fatalf("catalog lost label or pricing: %#v", channel) + } + } + request.ChannelLabel = strings.Repeat("字", 81) + if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err == nil { + t.Fatal("accepted oversized channel label") + } + request.ChannelLabel = " " + request.Description = strings.Repeat("字", 501) + if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err == nil { + t.Fatal("accepted oversized description") + } + request.Description = strings.Repeat("字", 500) + if _, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request); err != nil { + t.Fatalf("rejected valid 500-character description: %v", err) + } + request.Description = " " + updated, err := svc.SaveAdminChannelModel(admin, "channel-a", firstID, request) + if err != nil || updated.ChannelLabel != "" || updated.Description != "" || updated.ID != firstID { + t.Fatalf("label clearing changed identity: %#v, %v", updated, err) + } + if _, err := svc.SaveAdminChannelModel(&model.User{ID: "user", Role: model.UserRoleUser}, "channel-a", firstID, request); err == nil { + t.Fatal("non-admin changed channel label") + } +} diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index d105dade0..44945896a 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -21,6 +21,8 @@ type ChannelModelRequest struct { ModelKey string `json:"modelKey"` ProviderModelKey string `json:"providerModelKey"` DisplayName string `json:"displayName"` + ChannelLabel string `json:"channelLabel"` + Description string `json:"description"` Icon string `json:"icon"` Capability string `json:"capability"` Protocol string `json:"protocol"` @@ -40,7 +42,7 @@ const maxAdminChannelModelBatchDeleteCount = 100 // ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 // Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 type ChannelModelPriceTierRequest struct { - // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount; + // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount、videoGenerateAudio; // operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。 Selector map[string]string `json:"selector"` Resolution string `json:"resolution"` @@ -296,6 +298,14 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if err := s.RequireAdmin(actor); err != nil { return nil, err } + channelLabel := strings.TrimSpace(req.ChannelLabel) + description := strings.TrimSpace(req.Description) + if len([]rune(description)) > 500 { + return nil, BadAuthRequest("模型描述不能超过 500 字") + } + if len([]rune(channelLabel)) > 80 { + return nil, BadAuthRequest("渠道展示名不能超过 80 字") + } channel, err := s.repo.AdminSystemChannel(channelID) if err != nil { return nil, err @@ -347,6 +357,8 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.ModelKey = modelKey item.ProviderModelKey = providerModelKey item.DisplayName = strings.TrimSpace(req.DisplayName) + item.ChannelLabel = channelLabel + item.Description = description if item.DisplayName == "" { item.DisplayName = modelKey } @@ -437,8 +449,8 @@ func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelMo return nil } -func supportsTokenBilling(capability string, protocol model.ChannelInterfaceType) bool { - return capability == "text" || (capability == "video" && model.IsVolcengineArkVideoProtocol(protocol)) +func supportsTokenBilling(capability string, _ model.ChannelInterfaceType) bool { + return capability == "text" || capability == "video" } func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capability string, protocol model.ChannelInterfaceType, fallbackProviderModelKey string) ([]model.ChannelModelPriceTier, error) { @@ -537,6 +549,17 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric continue } value = strconv.Itoa(count) + case "videoGenerateAudio": + if capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按是否生成音频配置价格档") + } + value = strings.ToLower(value) + if value == "*" { + continue + } + if value != "true" && value != "false" { + return nil, "", 0, BadAuthRequest("视频价格档音频条件必须为 true、false 或 *") + } default: return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) } @@ -589,19 +612,15 @@ func validateChannelModelTierPricing(capability string, protocol model.ChannelIn return BadAuthRequest("只有视频模型可以按秒计费") } if billingMode == "token" && !supportsTokenBilling(capability, protocol) { - return BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议") + return BadAuthRequest("Token 计费仅支持文本和视频模型") } if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 { return BadAuthRequest("模型积分价格不能小于 0") } - if !input.PriceConfigured { - return nil + if billingMode == "token" { + return validateTokenPrices(capability, input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) } - const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale - if input.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { - return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") - } - return nil + return validateTokenPrices("", input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) } func normalizeChannelModelTierResolution(raw string) string { diff --git a/backend/internal/app/finance.go b/backend/internal/app/finance.go index 295c7acb3..9a529f3d0 100644 --- a/backend/internal/app/finance.go +++ b/backend/internal/app/finance.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" @@ -106,6 +107,8 @@ type ResolveBillingBatchResult struct { type tokenBillingEstimate struct { InputTokens int64 OutputTokens int64 + Video *VideoTokenEstimate + Err error } func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) { @@ -505,6 +508,9 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i if channelModel.Capability != capability || !supportsTokenBilling(capability, channelModel.Protocol) { return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式") } + if capability == "video" && (logicalModel.InputPriceMicrocredits != 0 || logicalModel.CachedPriceMicrocredits != 0) { + return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0") + } pricing := &model.ChannelModel{InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits} amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000) quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens @@ -514,13 +520,17 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i if err != nil { return nil, err } - if amount <= 0 { + if amount < 0 { return nil, BadAuthRequest("当前模型尚未配置有效的用户价格") } revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) if err != nil { return nil, err } + var videoFormulaTokens int64 + if logicalModel.BillingMode == "token" && tokenEstimate.Video != nil { + videoFormulaTokens = tokenEstimate.Video.FormulaTokens + } return &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: "task:" + task.ID + ":" + newID(), TaskID: task.ID, ChannelID: channelModel.ChannelID, ChannelModelID: channelModel.ID, Model: logicalModel.Code, Capability: capability, @@ -528,7 +538,8 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i UnitPriceMicrocredits: logicalModel.UnitPriceMicrocredits, MultiplierBasisPoints: 10_000, Quantity: quantity, AmountMicrocredits: amount, ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits, - Status: model.BillingStatusReserved, + VideoFormulaTokens: videoFormulaTokens, + Status: model.BillingStatusReserved, }, nil } @@ -589,14 +600,20 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide } quantity = requestedQuantity case "token": + if tokenEstimate.Err != nil { + return nil, tokenEstimate.Err + } if !supportsTokenBilling(item.Capability, item.Protocol) || item.Capability != capability { - return nil, BadAuthRequest("Token 计费仅支持文本生成和火山方舟视频生成") + return nil, BadAuthRequest("Token 计费仅支持文本和视频生成") } if capability == "text" && (tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0) { return nil, BadAuthRequest("无法估算文本 Token 用量") } if capability == "video" && tokenEstimate.OutputTokens <= 0 { - return nil, BadAuthRequest("无法估算火山方舟视频 Token 用量") + return nil, BadAuthRequest("无法计算视频 Token 用量,请检查时长与尺寸") + } + if capability == "video" && (tier.InputTokenPriceMicrocredits != 0 || tier.CachedTokenPriceMicrocredits != 0) { + return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0") } quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens default: @@ -618,6 +635,10 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide if err != nil { return nil, err } + var videoFormulaTokens int64 + if tier.BillingMode == "token" && tokenEstimate.Video != nil { + videoFormulaTokens = tokenEstimate.Video.FormulaTokens + } return &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID, ChannelID: channelID, ChannelModelID: item.ID, PriceTierID: tier.ID, PriceTierVersion: tier.PriceVersion, PriceSelectorJSON: tier.SelectorJSON, Model: modelKey, Capability: capability, @@ -625,7 +646,8 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide UnitPriceMicrocredits: tier.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount, ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits, - Status: model.BillingStatusReserved, + VideoFormulaTokens: videoFormulaTokens, + Status: model.BillingStatusReserved, }, nil } @@ -661,94 +683,6 @@ func estimateTaskBillingTokens(input map[string]any, capability string) tokenBil return estimateTaskTokens(input) } -// 方舟视频成功后才返回真实 completion_tokens;创建任务前按官方像素帧公式预授权, -// 并保留少量帧率/取整余量,实际结算时会按 usage 自动退回差额。 -func estimateArkVideoTokens(input map[string]any) tokenBillingEstimate { - config, _ := input["config"].(map[string]any) - if config == nil { - return tokenBillingEstimate{} - } - durationSeconds, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(config["videoSeconds"])), 10, 64) - if err != nil || durationSeconds <= 0 { - return tokenBillingEstimate{} - } - pixels := arkVideoOutputPixels(fmt.Sprint(config["vquality"]), fmt.Sprint(config["size"]), fmt.Sprint(config["model"])) - if pixels <= 0 { - return tokenBillingEstimate{} - } - - if durationSeconds > (1<<63-1)/1000 { - return tokenBillingEstimate{} - } - totalDurationMillis := durationSeconds * 1000 - referenceCount := int64(0) - if references, ok := input["referenceVideos"].([]any); ok && len(references) > 0 { - referenceCount = int64(len(references)) - knownDurationMillis := int64(0) - unknownDuration := false - for _, raw := range references { - media, _ := raw.(map[string]any) - durationMillis := firstInt64(media, "durationMs") - if durationMillis <= 0 { - unknownDuration = true - continue - } - knownDurationMillis = min(15_000, knownDurationMillis+min(durationMillis, int64(15_000))) - } - // 方舟参考视频总时长上限为 15 秒;缺少媒体元数据时按上限预留。 - if unknownDuration { - knownDurationMillis = 15_000 - } - totalDurationMillis += knownDurationMillis - } - frames := (totalDurationMillis*24+999)/1000 + 1 + referenceCount - if pixels > (1<<63-1-1023)/frames { - return tokenBillingEstimate{} - } - tokens := (pixels*frames + 1023) / 1024 - if tokens > (1<<63-1-99)/110 { - return tokenBillingEstimate{} - } - return tokenBillingEstimate{OutputTokens: (tokens*110 + 99) / 100} -} - -func arkVideoOutputPixels(resolution string, ratio string, modelName string) int64 { - resolution = normalizeSeedanceResolution(resolution, modelName) - ratio = normalizeSeedanceRatio(ratio) - pixelsByRatio := map[string]map[string]int64{ - "480p": { - "16:9": 864 * 496, "4:3": 752 * 560, "1:1": 640 * 640, - "3:4": 560 * 752, "9:16": 496 * 864, "21:9": 992 * 432, - }, - "720p": { - "16:9": 1280 * 720, "4:3": 1112 * 834, "1:1": 960 * 960, - "3:4": 834 * 1112, "9:16": 720 * 1280, "21:9": 1470 * 630, - }, - "1080p": { - "16:9": 1920 * 1080, "4:3": 1664 * 1248, "1:1": 1440 * 1440, - "3:4": 1248 * 1664, "9:16": 1080 * 1920, "21:9": 2206 * 946, - }, - } - values := pixelsByRatio[resolution] - if resolution == "2160p" { - values = make(map[string]int64, len(pixelsByRatio["1080p"])) - for key, value := range pixelsByRatio["1080p"] { - values[key] = value * 4 - } - } - if len(values) == 0 { - return 0 - } - if ratio != "adaptive" { - return values[ratio] - } - var largest int64 - for _, value := range values { - largest = max(largest, value) - } - return largest -} - func estimateProxyTokens(body []byte) tokenBillingEstimate { var payload map[string]any _ = json.Unmarshal(body, &payload) @@ -781,35 +715,25 @@ func maxOutputTokens(payload map[string]any) int64 { // Token 单价按每百万 Token 配置;预授权使用输入价估算缓存 Token,真实结算再按 usage 拆分。 func tokenEstimateAmount(item *model.ChannelModel, estimate tokenBillingEstimate, multiplierBPS int64) (int64, error) { + if estimate.Err != nil { + return 0, estimate.Err + } if item == nil || estimate.InputTokens < 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 { return 0, errors.New("Token 计费参数无效") } - inputAmount, ok := safeTokenProduct(estimate.InputTokens, item.InputTokenPriceMicrocredits) - if !ok { + amount, err := kernel.TokenBillingAmount(multiplierBPS, + kernel.TokenBillingTerm{Tokens: estimate.InputTokens, PriceMicrocredits: item.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: estimate.OutputTokens, PriceMicrocredits: item.OutputTokenPriceMicrocredits}, + ) + if errors.Is(err, kernel.ErrTokenBillingOverflow) { return 0, errors.New("Token 计费金额溢出") } - outputAmount, ok := safeTokenProduct(estimate.OutputTokens, item.OutputTokenPriceMicrocredits) - if !ok || inputAmount > 1<<63-1-outputAmount { - return 0, errors.New("Token 计费金额溢出") - } - base := inputAmount + outputAmount - if base > (1<<63-1-9_999_999_999)/multiplierBPS { - return 0, errors.New("Token 计费金额溢出") - } - amount := (base*multiplierBPS + 9_999_999_999) / 10_000_000_000 - if amount <= 0 { - return 0, errors.New("Token 计费金额必须大于 0") + if err != nil { + return 0, errors.New("Token 计费参数无效") } return amount, nil } -func safeTokenProduct(tokens int64, price int64) (int64, bool) { - if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) { - return 0, false - } - return tokens * price, true -} - func billingQuantity(capability string, value any) int64 { if capability != "video" { return 1 diff --git a/backend/internal/app/finance_token_arithmetic_test.go b/backend/internal/app/finance_token_arithmetic_test.go new file mode 100644 index 000000000..bdaa13f77 --- /dev/null +++ b/backend/internal/app/finance_token_arithmetic_test.go @@ -0,0 +1,73 @@ +package app + +import ( + "math" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestTokenEstimateAmountUsesExactArithmetic(t *testing.T) { + for _, test := range []struct { + name string + prices model.ChannelModel + estimate tokenBillingEstimate + multiplier int64 + want int64 + }{ + { + name: "video maximum price with multiplier", + prices: model.ChannelModel{ + InputTokenPriceMicrocredits: 1_000_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000, + }, + estimate: tokenBillingEstimate{OutputTokens: 10_000_000}, multiplier: 12_500, want: 12_500_000_000_000, + }, + { + name: "text estimates input at full input price", + prices: model.ChannelModel{ + InputTokenPriceMicrocredits: 2_000_000, OutputTokenPriceMicrocredits: 5_000_000, CachedTokenPriceMicrocredits: 1_000_000, + }, + estimate: tokenBillingEstimate{InputTokens: 100, OutputTokens: 20}, multiplier: 10_000, want: 300, + }, + { + name: "round after combining input and output", + prices: model.ChannelModel{ + InputTokenPriceMicrocredits: 400_000, OutputTokenPriceMicrocredits: 400_000, + }, + estimate: tokenBillingEstimate{InputTokens: 1, OutputTokens: 1}, multiplier: 12_500, want: 1, + }, + { + name: "maximum final amount", + prices: model.ChannelModel{OutputTokenPriceMicrocredits: 1_000_000}, + estimate: tokenBillingEstimate{OutputTokens: math.MaxInt64}, multiplier: 10_000, want: math.MaxInt64, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := tokenEstimateAmount(&test.prices, test.estimate, test.multiplier) + if err != nil || amount != test.want { + t.Fatalf("tokenEstimateAmount() = %d, %v; want %d", amount, err, test.want) + } + }) + } +} + +func TestTokenEstimateAmountRejectsInvalidArithmetic(t *testing.T) { + for _, test := range []struct { + name string + prices model.ChannelModel + estimate tokenBillingEstimate + multiplier int64 + }{ + {name: "negative input", estimate: tokenBillingEstimate{InputTokens: -1, OutputTokens: 1}, multiplier: 10_000}, + {name: "negative output", estimate: tokenBillingEstimate{OutputTokens: -1}, multiplier: 10_000}, + {name: "negative price", prices: model.ChannelModel{OutputTokenPriceMicrocredits: -1}, estimate: tokenBillingEstimate{OutputTokens: 1}, multiplier: 10_000}, + {name: "zero multiplier", estimate: tokenBillingEstimate{OutputTokens: 1}}, + {name: "final overflow", prices: model.ChannelModel{OutputTokenPriceMicrocredits: 1_000_000}, estimate: tokenBillingEstimate{OutputTokens: math.MaxInt64}, multiplier: 10_001}, + } { + t.Run(test.name, func(t *testing.T) { + if amount, err := tokenEstimateAmount(&test.prices, test.estimate, test.multiplier); amount != 0 || err == nil { + t.Fatalf("invalid estimate billed %d, %v", amount, err) + } + }) + } +} diff --git a/backend/internal/app/finance_token_test.go b/backend/internal/app/finance_token_test.go index ecd83ceb0..fdae92c10 100644 --- a/backend/internal/app/finance_token_test.go +++ b/backend/internal/app/finance_token_test.go @@ -15,8 +15,8 @@ func TestSupportsTokenBillingForVolcengineArkVideo(t *testing.T) { if !supportsTokenBilling("video", model.ChannelInterfaceVolcengineArkVideo) { t.Fatal("Volcengine Ark video should support Token billing") } - if supportsTokenBilling("video", model.ChannelInterfaceNewAPIVideo) { - t.Fatal("video protocols without a final usage contract must not support Token billing") + if !supportsTokenBilling("video", model.ChannelInterfaceNewAPIVideo) { + t.Fatal("all video protocols must support formula-based Token billing") } } @@ -26,8 +26,8 @@ func TestEstimateArkVideoTokensUsesPixelFrameEstimate(t *testing.T) { "model": "doubao-seedance-1-5-pro", "videoSeconds": "5", "vquality": "720", "size": "16:9", }, }) - // 1280*720*(5*24+1)/1024 = 108900,预授权再保留 10% 余量。 - if estimate.InputTokens != 0 || estimate.OutputTokens != 119790 { + // 官方公式 1280*720*5*24/1024 = 108000;平台另预留 10%。 + if estimate.Err != nil || estimate.InputTokens != 0 || estimate.OutputTokens != 118800 { t.Fatalf("estimateArkVideoTokens() = %#v", estimate) } } @@ -39,20 +39,20 @@ func TestEstimateArkVideoTokensIncludesUnknownReferenceVideo(t *testing.T) { }, "referenceVideos": []any{map[string]any{"id": "video-1"}}, }) - if estimate.OutputTokens != 477180 { + if estimate.Err != nil || estimate.OutputTokens != 475200 || !estimate.Video.ReferenceDurationEstimated { t.Fatalf("estimateArkVideoTokens() = %#v", estimate) } } -func TestEstimateArkVideoTokensCapsReferenceDuration(t *testing.T) { +func TestEstimateVideoTokensIncludesLongReferenceDuration(t *testing.T) { estimate := estimateArkVideoTokens(map[string]any{ "config": map[string]any{ "model": "doubao-seedance-2-0", "videoSeconds": "5", "vquality": "720p", "size": "16:9", }, "referenceVideos": []any{map[string]any{"id": "video-1", "durationMs": int64(60_000)}}, }) - if estimate.OutputTokens != 477180 { - t.Fatalf("estimateArkVideoTokens() = %#v", estimate) + if estimate.Err != nil || estimate.Video.ReferenceSeconds != 60 || estimate.Video.FormulaTokens != 1_404_000 { + t.Fatalf("reference duration must be counted without silent capping: %#v", estimate) } } diff --git a/backend/internal/app/logical_model_quote.go b/backend/internal/app/logical_model_quote.go index 647aebd7d..1463f8462 100644 --- a/backend/internal/app/logical_model_quote.go +++ b/backend/internal/app/logical_model_quote.go @@ -1,18 +1,78 @@ package app -import "infinite-canvas/backend/internal/model" +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) // LogicalModelQuote 是创作端当前参数命中的实际供应线路报价。 // Token 视频在上游返回真实 usage 前只能预估,创建任务仍以账务预留逻辑为准。 type LogicalModelQuote struct { - LogicalModelID string `json:"logicalModelId"` - BillingMode string `json:"billingMode"` - Quantity int64 `json:"quantity"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Estimated bool `json:"estimated"` + LogicalModelID string `json:"logicalModelId"` + BillingMode string `json:"billingMode"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Estimated bool `json:"estimated"` + VideoTokenEstimate *VideoTokenEstimate `json:"videoTokenEstimate,omitempty"` +} + +type ChannelModelQuoteRequest struct { + ChannelID string `json:"channelId"` + ModelKey string `json:"modelKey"` + Intent ModelRequestIntent `json:"intent"` +} + +// QuoteChannelModel uses the same admission/defaults/SKU selection as task +// creation, without reserving credits or submitting a provider request. +func (s *Service) QuoteChannelModel(req ChannelModelQuoteRequest) (*LogicalModelQuote, error) { + if strings.TrimSpace(req.ChannelID) == "" || strings.TrimSpace(req.ModelKey) == "" { + return nil, InvalidModelSelection("报价必须指定系统渠道和模型") + } + if err := validateQuoteIntent(req.Intent); err != nil { + return nil, err + } + input := quoteInput(req.Intent, req.ModelKey) + config := input["config"].(map[string]any) + config["channelId"] = req.ChannelID + input, err := s.resolveSystemChannelModelSelection(input, "canvas_"+req.Intent.Capability, req.Intent.Operation) + if err != nil { + return nil, err + } + config = input["config"].(map[string]any) + estimate := estimateTaskBillingTokens(input, req.Intent.Capability) + intent := ModelRequestIntentFromTaskInput(input, "canvas_"+req.Intent.Capability, req.Intent.Operation) + order, err := s.newBillingOrderWithPriceTier("", "", "quote", req.ChannelID, req.ModelKey, req.Intent.Capability, "model_quote", billingQuantity(req.Intent.Capability, config["videoSeconds"]), estimate, stringValue(config["priceTierId"]), intent) + if err != nil { + return nil, err + } + return quoteFromOrder("", order, estimate), nil +} + +func validateQuoteIntent(intent ModelRequestIntent) error { + if intent.Capability != "text" && intent.Capability != "image" && intent.Capability != "video" && intent.Capability != "audio" { + return BadAuthRequest("报价请求缺少有效的模型能力类型") + } + for _, count := range intent.Inputs { + if count < 0 || count > 128 { + return BadAuthRequest("报价参考素材数量必须为 0 到 128 的整数") + } + } + return nil +} + +func quoteFromOrder(logicalModelID string, order *model.BillingOrder, estimate tokenBillingEstimate) *LogicalModelQuote { + quote := &LogicalModelQuote{LogicalModelID: logicalModelID, BillingMode: order.BillingMode, Quantity: order.Quantity, AmountMicrocredits: order.AmountMicrocredits, Estimated: order.BillingMode == "token"} + if order.BillingMode == "token" && order.Capability == "video" { + quote.VideoTokenEstimate = estimate.Video + } + return quote } func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIntent) (*LogicalModelQuote, error) { + if err := validateQuoteIntent(intent); err != nil { + return nil, err + } routed, err := s.ResolveLogicalModel(logicalModelID, intent) if err != nil { return nil, err @@ -24,6 +84,11 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn resolvedIntent := intent resolvedIntent.Options = mergeIntentDefaults(intent.Options, routed.Defaults) input := quoteInput(resolvedIntent, routed.ChannelModel.ModelKey) + providerModelKey := routed.ChannelModel.ProviderModelKey + if routed.PriceTier != nil { + providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey) + } + input["config"].(map[string]any)["providerModelKey"] = providerModelKey quantity := billingQuantity(capability, inputConfigValue(input, "videoSeconds")) if capability != "video" { quantity = 1 @@ -39,13 +104,7 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if billingErr != nil { return nil, billingErr } - return &LogicalModelQuote{ - LogicalModelID: routed.LogicalModel.ID, - BillingMode: order.BillingMode, - Quantity: order.Quantity, - AmountMicrocredits: order.AmountMicrocredits, - Estimated: order.BillingMode == "token", - }, nil + return quoteFromOrder(routed.LogicalModel.ID, order, tokenEstimate), nil } if routed.LogicalModel.PricePolicy != "unified" { @@ -65,6 +124,9 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if routed.ChannelModel.Capability != capability || !supportsTokenBilling(capability, routed.ChannelModel.Protocol) { return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式") } + if capability == "video" && (routed.LogicalModel.InputPriceMicrocredits != 0 || routed.LogicalModel.CachedPriceMicrocredits != 0) { + return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0") + } pricing := &model.ChannelModel{ InputTokenPriceMicrocredits: routed.LogicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: routed.LogicalModel.OutputPriceMicrocredits, @@ -78,16 +140,20 @@ func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIn if err != nil { return nil, err } - if amount <= 0 { + if amount < 0 { return nil, BadAuthRequest("当前模型尚未配置有效的用户价格") } - return &LogicalModelQuote{ + quote := &LogicalModelQuote{ LogicalModelID: routed.LogicalModel.ID, BillingMode: routed.LogicalModel.BillingMode, Quantity: quantity, AmountMicrocredits: amount, Estimated: routed.LogicalModel.BillingMode == "token", - }, nil + } + if quote.Estimated && capability == "video" { + quote.VideoTokenEstimate = tokenEstimate.Video + } + return quote, nil } func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any { @@ -97,9 +163,10 @@ func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any { } config["model"] = modelKey input := map[string]any{"mode": intent.Capability, "config": config} - if count := intent.Inputs["video"]; count > 0 { - references := make([]any, count) - input["referenceVideos"] = references + for kind, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { + if count := intent.Inputs[kind]; count > 0 { + input[key] = make([]any, count) + } } return input } diff --git a/backend/internal/app/logical_models.go b/backend/internal/app/logical_models.go index ebb5fa08d..8665a2353 100644 --- a/backend/internal/app/logical_models.go +++ b/backend/internal/app/logical_models.go @@ -664,6 +664,11 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if pricePolicy == "unified" && billingMode == "per_second" && capability != "video" { return nil, nil, nil, false, BadAuthRequest("只有视频前台模型可以按秒计费") } + if pricePolicy == "unified" && billingMode == "token" { + if err := validateTokenPrices(capability, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits); err != nil { + return nil, nil, nil, false, err + } + } creating := strings.TrimSpace(id) == "" var item *model.LogicalModel if creating { @@ -756,7 +761,7 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo } if pricePolicy == "unified" && billingMode == "token" { if !supportsLogicalModelTokenBilling(capability, enabledRouteProtocols) { - return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本前台模型,或全部启用供应线路均为火山方舟视频协议的视频前台模型") + return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本和视频前台模型") } } // 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。 diff --git a/backend/internal/app/logical_models_test.go b/backend/internal/app/logical_models_test.go index d36c4bcde..a43483a78 100644 --- a/backend/internal/app/logical_models_test.go +++ b/backend/internal/app/logical_models_test.go @@ -279,8 +279,8 @@ func TestSupportsLogicalModelTokenBillingForArkVideoRoutes(t *testing.T) { if supportsLogicalModelTokenBilling("video", nil) { t.Fatal("video logical models without an enabled route must not support Token billing") } - if supportsLogicalModelTokenBilling("video", []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceNewAPIVideo}) { - t.Fatal("mixed video protocols must not support Token billing") + if !supportsLogicalModelTokenBilling("video", []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceNewAPIVideo}) { + t.Fatal("all video protocols must support Token billing") } } diff --git a/backend/internal/app/model_catalog.go b/backend/internal/app/model_catalog.go index 0fa48db2a..83d6a6612 100644 --- a/backend/internal/app/model_catalog.go +++ b/backend/internal/app/model_catalog.go @@ -41,6 +41,8 @@ type PublicChannelCatalog struct { type PublicChannelModel struct { ID string `json:"id"` ModelKey string `json:"modelKey"` + ChannelLabel string `json:"channelLabel"` + Description string `json:"description"` DisplayName string `json:"displayName"` SortOrder int `json:"sortOrder"` Icon string `json:"icon"` @@ -201,6 +203,8 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod return PublicChannelModel{ ID: cm.ID, ModelKey: cm.ModelKey, + ChannelLabel: cm.ChannelLabel, + Description: cm.Description, DisplayName: cm.DisplayName, SortOrder: cm.SortOrder, Icon: cm.Icon, diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 4d2860c02..02c58116f 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -758,6 +758,9 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { selector["videoSeconds"] = strconv.Itoa(seconds) } + if audio := normalizedScalar(intent.Options["videoGenerateAudio"]); audio == "true" || audio == "false" { + selector["videoGenerateAudio"] = audio + } case "image": if intent.Inputs["image"] > 0 { selector["operation"] = "image_to_image" diff --git a/backend/internal/app/price_validation.go b/backend/internal/app/price_validation.go index 060311779..59d6deb74 100644 --- a/backend/internal/app/price_validation.go +++ b/backend/internal/app/price_validation.go @@ -2,6 +2,21 @@ package app import "infinite-canvas/backend/internal/model" +const maxChannelModelTokenPriceMicrocredits = int64(1_000_000) * CreditScale + +func validateTokenPrices(capability string, inputPrice, outputPrice, cachedPrice int64) error { + if inputPrice < 0 || outputPrice < 0 || cachedPrice < 0 { + return BadAuthRequest("模型积分价格不能小于 0") + } + if capability == "video" && (inputPrice != 0 || cachedPrice != 0) { + return BadAuthRequest("视频 Token 仅按视频用量计费,输入和缓存 Token 价格必须为 0") + } + if inputPrice > maxChannelModelTokenPriceMicrocredits || outputPrice > maxChannelModelTokenPriceMicrocredits || cachedPrice > maxChannelModelTokenPriceMicrocredits { + return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") + } + return nil +} + // ValidateChannelModelPrice 校验渠道模型价格配置的有效性 // 根据 billingMode 检查必要的价格字段是否已配置 func ValidateChannelModelPrice(billingMode string, capability string, protocol model.ChannelInterfaceType, unitPrice, inputPrice, outputPrice, cachedPrice int64) bool { @@ -13,14 +28,17 @@ func ValidateChannelModelPrice(billingMode string, capability string, protocol m // 按秒计费:0 表示免费,负数无效。 return unitPrice >= 0 case "token": + if validateTokenPrices(capability, inputPrice, outputPrice, cachedPrice) != nil { + return false + } if capability == "video" { - return model.IsVolcengineArkVideoProtocol(protocol) && inputPrice >= 0 && outputPrice >= 0 && cachedPrice >= 0 + return true } if capability != "" && capability != "text" { return false } // 文本模型:所有 Token 价格为 0 时表示免费。 - return inputPrice >= 0 && outputPrice >= 0 && cachedPrice >= 0 + return true default: return false } diff --git a/backend/internal/app/price_validation_test.go b/backend/internal/app/price_validation_test.go index d1c09405d..97a6627ba 100644 --- a/backend/internal/app/price_validation_test.go +++ b/backend/internal/app/price_validation_test.go @@ -6,14 +6,14 @@ import ( "infinite-canvas/backend/internal/model" ) -func TestValidateChannelModelPriceSupportsArkVideoTokensOnly(t *testing.T) { +func TestValidateChannelModelPriceSupportsAllVideoTokens(t *testing.T) { const outputPrice = int64(16_000_000) if !ValidateChannelModelPrice("token", "video", model.ChannelInterfaceVolcengineArkVideo, 0, 0, outputPrice, 0) { t.Fatal("Volcengine Ark video Token price should be valid") } for _, protocol := range []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceNewAPIVideo} { - if ValidateChannelModelPrice("token", "video", protocol, 0, 0, outputPrice, 0) { - t.Fatalf("video protocol %q should not support Token pricing", protocol) + if !ValidateChannelModelPrice("token", "video", protocol, 0, 0, outputPrice, 0) { + t.Fatalf("video protocol %q should support Token pricing", protocol) } } } @@ -26,7 +26,7 @@ func TestHasValidPriceUsesChannelProtocolForTokenTiers(t *testing.T) { } jimeng := &model.ChannelModel{Capability: "video", Protocol: model.ChannelInterfaceVolcengineJiMengVideo, PriceTiers: []model.ChannelModelPriceTier{tier}} - if HasValidPrice(jimeng) { - t.Fatal("JiMeng video Token tier should be invalid") + if !HasValidPrice(jimeng) { + t.Fatal("JiMeng video Token tier should be valid") } } diff --git a/backend/internal/app/task_creation.go b/backend/internal/app/task_creation.go index 87a59f198..93dfb583b 100644 --- a/backend/internal/app/task_creation.go +++ b/backend/internal/app/task_creation.go @@ -230,10 +230,12 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map nextConfig["channelId"] = routed.ChannelModel.ChannelID nextConfig["model"] = routed.ChannelModel.ModelKey nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey + providerModelKey := routed.ChannelModel.ProviderModelKey if routed.PriceTier != nil { nextConfig["priceTierId"] = routed.PriceTier.ID - nextConfig["providerModelKey"] = routed.PriceTier.ProviderModelKey + providerModelKey = firstNonEmpty(routed.PriceTier.ProviderModelKey, providerModelKey) } + nextConfig["providerModelKey"] = firstNonEmpty(providerModelKey, routed.ChannelModel.ModelKey) input["config"] = nextConfig return input } diff --git a/backend/internal/app/video_logical_billing_test.go b/backend/internal/app/video_logical_billing_test.go new file mode 100644 index 000000000..779741844 --- /dev/null +++ b/backend/internal/app/video_logical_billing_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "encoding/json" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestUnifiedVideoQuoteAndTaskUseResolvedProviderGeneration(t *testing.T) { + svc, db, _, channelModel, profile := newVideoTokenQuoteFixture(t) + const upstream = "doubao-seedance-2-5" + channelModel.ProviderModelKey = upstream + if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("provider_model_key", upstream).Error; err != nil { + t.Fatal(err) + } + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video") + if err != nil { + t.Fatal(err) + } + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + logical := model.LogicalModel{ + ID: "logical-aliased-video", Code: "video-alias", Capability: "video", Enabled: true, + ActiveRevisionID: "aliased-video-revision", PricePolicy: "unified", BillingMode: "token", OutputPriceMicrocredits: 16_000_000, + } + revision := model.LogicalModelRevision{ + ID: logical.ActiveRevisionID, LogicalModelID: logical.ID, Version: 1, CapabilitySpecJSON: string(specJSON), + DefaultOptionsJSON: `{"vquality":"480p","size":"16:9","videoSeconds":5,"videoGenerateAudio":true}`, + } + route := model.LogicalModelRoute{ID: "aliased-video-route", LogicalModelRevisionID: revision.ID, ChannelModelID: channelModel.ID, Enabled: true, Weight: 1} + for _, row := range []any{&logical, &revision, &route} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + quote, err := svc.QuoteLogicalModel(logical.ID, ModelRequestIntent{Capability: "video"}) + if err != nil { + t.Fatal(err) + } + routed, input, err := svc.resolveTaskModelSelection(map[string]any{"mode": "video", "config": map[string]any{}}, logical.ID, "canvas_video", "", true) + if err != nil { + t.Fatal(err) + } + if routed.PriceTier != nil || input["config"].(map[string]any)["providerModelKey"] != upstream { + t.Fatalf("unified route did not preserve its provider model: %#v", input) + } + order, err := svc.taskBillingOrder("quote-user", &model.Task{ + ID: "aliased-video-task", Type: "canvas_video", LogicalModelID: logical.ID, + LogicalModelRevisionID: revision.ID, RouteID: route.ID, ChannelModelID: channelModel.ID, + }, input) + if err != nil { + t.Fatal(err) + } + if quote.VideoTokenEstimate == nil || quote.VideoTokenEstimate.OutputWidth != 854 || quote.VideoTokenEstimate.OutputHeight != 480 || quote.VideoTokenEstimate.DimensionsEstimated { + t.Fatalf("wrong generation-specific quote: %#v", quote.VideoTokenEstimate) + } + if order == nil || order.VideoFormulaTokens != 48038 || order.Quantity != 52842 || order.AmountMicrocredits != 845472 || quote.Quantity != order.Quantity || quote.AmountMicrocredits != order.AmountMicrocredits { + t.Fatalf("quote/task mismatch: quote=%#v order=%#v", quote, order) + } + assertVideoQuoteHasNoFinancialWrites(t, db) +} + +func TestRoutedProviderSelectionFallsBackWithoutTierOverride(t *testing.T) { + for _, test := range []struct { + name string + provider string + tier *model.ChannelModelPriceTier + want string + }{ + {name: "unified without tier", provider: "provider-default", want: "provider-default"}, + {name: "empty tier override", provider: "provider-default", tier: &model.ChannelModelPriceTier{ID: "tier"}, want: "provider-default"}, + {name: "tier override", provider: "provider-default", tier: &model.ChannelModelPriceTier{ID: "tier", ProviderModelKey: "provider-tier"}, want: "provider-tier"}, + {name: "model key fallback", tier: &model.ChannelModelPriceTier{ID: "tier"}, want: "model-alias"}, + } { + t.Run(test.name, func(t *testing.T) { + input := applyRoutedProviderSelection(map[string]any{"config": map[string]any{"providerModelKey": "untrusted-client-model"}}, &RoutedModel{ + ChannelModel: model.ChannelModel{ChannelID: "channel", ModelKey: "model-alias", ProviderModelKey: test.provider}, PriceTier: test.tier, + }) + if got := input["config"].(map[string]any)["providerModelKey"]; got != test.want { + t.Fatalf("providerModelKey = %v, want %s", got, test.want) + } + }) + } +} + +func TestUnifiedVideoModelSaveRejectsInvalidTokenPricesBeforePersistence(t *testing.T) { + for _, test := range []struct { + name string + input, output int64 + cached int64 + }{ + {name: "hidden input", input: 1, output: 16_000_000}, + {name: "hidden cache", output: 16_000_000, cached: 1}, + {name: "too expensive", output: maxChannelModelTokenPriceMicrocredits + 1}, + } { + t.Run(test.name, func(t *testing.T) { + // A nil repository proves invalid price fields fail before IDs or revisions are written. + _, _, _, _, err := (&Service{}).logicalModelBundle(&model.User{ID: "admin"}, "", LogicalModelRequest{ + Code: "video-test", Name: "Video", Capability: "video", PricePolicy: "unified", BillingMode: "token", + CapabilitySpec: CapabilitySpec{Version: 1, Capability: "video"}, + InputPriceMicrocredits: test.input, OutputPriceMicrocredits: test.output, CachedPriceMicrocredits: test.cached, + }) + if err == nil { + t.Fatal("invalid unified video pricing accepted") + } + }) + } +} + +func TestUnifiedVideoModelSaveAllowsFreeAndMaximumTokenPrices(t *testing.T) { + for _, outputPrice := range []int64{0, 16_000_000, maxChannelModelTokenPriceMicrocredits} { + svc, _, _, channelModel, profile := newVideoTokenQuoteFixture(t) + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video") + if err != nil { + t.Fatal(err) + } + item, _, _, _, err := svc.logicalModelBundle(&model.User{ID: "admin"}, "", LogicalModelRequest{ + Code: "video-test", Name: "Video", Capability: "video", PricePolicy: "unified", BillingMode: "token", Enabled: true, + CapabilitySpec: spec, OutputPriceMicrocredits: outputPrice, + Routes: []LogicalRouteRequest{{ChannelModelID: channelModel.ID, Enabled: true, Weight: 1}}, + }) + if err != nil || item == nil || item.OutputPriceMicrocredits != outputPrice { + t.Fatalf("output price %d: model=%#v error=%v", outputPrice, item, err) + } + } +} diff --git a/backend/internal/app/video_token_billing.go b/backend/internal/app/video_token_billing.go new file mode 100644 index 000000000..66d5f7631 --- /dev/null +++ b/backend/internal/app/video_token_billing.go @@ -0,0 +1,241 @@ +package app + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// VideoTokenEstimate separates the provider's pixel-frame estimate from our +// reservation margin. The formula snapshot can settle videos without provider usage. +type VideoTokenEstimate struct { + FormulaTokens int64 `json:"formulaTokens"` + ReservedTokens int64 `json:"reservedTokens"` + OutputWidth int64 `json:"outputWidth"` + OutputHeight int64 `json:"outputHeight"` + FramesPerSecond int64 `json:"framesPerSecond"` + OutputSeconds int64 `json:"outputSeconds"` + ReferenceSeconds float64 `json:"referenceSeconds"` + ReferenceDurationEstimated bool `json:"referenceDurationEstimated"` + DimensionsEstimated bool `json:"dimensionsEstimated"` + ReservationMarginPercent int64 `json:"reservationMarginPercent"` +} + +type videoDimensions struct{ width, height int64 } + +// Official Seedance dimensions, not resolution-label multiplication. 4K's +// 4:3 and 21:9 dimensions in particular differ from twice the 1080p dimensions. +var arkVideoDimensions = map[string][]videoDimensions{ + "480p": {{864, 496}, {752, 560}, {640, 640}, {560, 752}, {496, 864}, {992, 432}}, + "720p": {{1280, 720}, {1112, 834}, {960, 960}, {834, 1112}, {720, 1280}, {1470, 630}}, + "1080p": {{1920, 1080}, {1664, 1248}, {1440, 1440}, {1248, 1664}, {1080, 1920}, {2206, 946}}, + "2160p": {{3840, 2160}, {3326, 2494}, {2880, 2880}, {2494, 3326}, {2160, 3840}, {4398, 1886}}, +} + +var arkVideo10Dimensions = map[string][]videoDimensions{ + "480p": {{864, 480}, {736, 544}, {640, 640}, {544, 736}, {480, 864}, {960, 416}}, + "720p": {{1248, 704}, {1120, 832}, {960, 960}, {832, 1120}, {704, 1248}, {1504, 640}}, + "1080p": {{1920, 1088}, {1664, 1248}, {1440, 1440}, {1248, 1664}, {1088, 1920}, {2176, 928}}, +} + +func estimateArkVideoTokens(input map[string]any) tokenBillingEstimate { + detail, err := estimateArkVideoTokenUsage(input) + if err != nil { + return tokenBillingEstimate{Err: err} + } + return tokenBillingEstimate{OutputTokens: detail.ReservedTokens, Video: detail} +} + +func estimateArkVideoTokenUsage(input map[string]any) (*VideoTokenEstimate, error) { + config, _ := input["config"].(map[string]any) + seconds, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(config["videoSeconds"])), 10, 64) + if err != nil || seconds <= 0 || seconds > (math.MaxInt64-15_000)/1000 { + return nil, BadAuthRequest("视频 Token 预估需要有效的正整数生成时长") + } + modelName := firstNonEmpty(stringValue(config["providerModelKey"]), stringValue(config["model"])) + dimensions, dimensionsEstimated, err := arkVideoBillingDimensions(stringValue(config["vquality"]), stringValue(config["size"]), modelName) + if err != nil { + return nil, err + } + referenceMillis, durationEstimated, err := videoReferenceMillis(input["referenceVideos"]) + if err != nil { + return nil, err + } + const fps, margin = int64(24), int64(10) + if dimensions.width > math.MaxInt64/dimensions.height/fps { + return nil, BadAuthRequest("视频尺寸超出 Token 计算范围") + } + pixels := dimensions.width * dimensions.height + if seconds > (math.MaxInt64-referenceMillis)/1000 { + return nil, BadAuthRequest("视频时长超出 Token 计算范围") + } + millis := seconds*1000 + referenceMillis + if millis > (math.MaxInt64-1_023_999)/(pixels*fps) { + return nil, BadAuthRequest("视频 Token 预估用量超出支持范围") + } + // Official formula: (input seconds + output seconds) * W * H * FPS / 1024. + // Provider usage takes precedence; otherwise the formula snapshot is billable. + formulaTokens := (millis*pixels*fps + 1_023_999) / 1_024_000 + if formulaTokens > (math.MaxInt64-99)/(100+margin) { + return nil, BadAuthRequest("视频 Token 预授权用量超出支持范围") + } + return &VideoTokenEstimate{ + FormulaTokens: formulaTokens, ReservedTokens: (formulaTokens*(100+margin) + 99) / 100, + OutputWidth: dimensions.width, OutputHeight: dimensions.height, FramesPerSecond: fps, + OutputSeconds: seconds, ReferenceSeconds: float64(referenceMillis) / 1000, + ReferenceDurationEstimated: durationEstimated, DimensionsEstimated: dimensionsEstimated, + ReservationMarginPercent: margin, + }, nil +} + +func videoReferenceMillis(raw any) (int64, bool, error) { + if raw == nil { + return 0, false, nil + } + references, ok := raw.([]any) + if !ok || len(references) > 128 { + return 0, false, BadAuthRequest("视频 Token 预估的参考视频参数无效") + } + var total int64 + unknown := false + for _, rawReference := range references { + media, ok := rawReference.(map[string]any) + if rawReference != nil && !ok { + return 0, false, BadAuthRequest("视频 Token 预估的参考视频参数无效") + } + value := media["durationMs"] + if value == nil { + unknown = true + continue + } + duration, err := strconv.ParseInt(fmt.Sprint(value), 10, 64) + if err != nil || duration < 0 || duration > math.MaxInt64-total { + return 0, false, BadAuthRequest("参考视频时长参数无效,durationMs 必须为非负整数且不能溢出") + } + if duration == 0 { + unknown = true + } + total += duration + } + if unknown { + // The catalog quote has only reference counts; reserve the documented + // maximum and disclose it instead of pretending the references cost zero. + return max(total, 15_000), true, nil + } + return total, false, nil +} + +func arkVideoBillingDimensions(resolution, ratio, modelName string) (videoDimensions, bool, error) { + resolution = strings.ToLower(strings.TrimSpace(resolution)) + ratio = strings.ToLower(strings.TrimSpace(ratio)) + // Some video protocols expose exact pixels in size instead of a resolution tier. + if dimensions, ok := videoPixelDimensions(ratio); ok { + return dimensions, false, nil + } + defaulted := resolution == "" || resolution == "auto" || resolution == "high" || resolution == "medium" + if defaulted { + resolution = "720p" + } + if resolution == "low" { + resolution = "480p" + } + if ratio == "" { + ratio = "16:9" + defaulted = true + } + switch resolution { + case "480", "720", "1080", "2160": + resolution += "p" + case "4k": + resolution = "2160p" + case "2k": + resolution = "1440p" + } + base, exists := arkVideoDimensions[resolution] + if !exists { + return videoScaledDimensions(resolution, ratio) + } + ratios := []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9"} + ratio = strings.ToLower(strings.TrimSpace(ratio)) + adaptive := ratio == "adaptive" || ratio == "auto" + ratioIndex := -1 + for index, value := range ratios { + if value == ratio { + ratioIndex = index + } + } + if ratioIndex < 0 && !adaptive { + return videoScaledDimensions(resolution, ratio) + } + name := strings.ReplaceAll(strings.ToLower(modelName), ".", "-") + is10 := strings.Contains(name, "seedance-1-0") + is25 := strings.Contains(name, "seedance-2-5") + known := is10 || is25 || strings.Contains(name, "seedance-1-5") || strings.Contains(name, "seedance-2-0") + values := append([]videoDimensions(nil), base...) + if is10 { + if resolution == "2160p" { + defaulted = true + } else { + values = arkVideo10Dimensions[resolution] + } + } else if is25 && resolution == "480p" { + values[0], values[4] = videoDimensions{854, 480}, videoDimensions{480, 854} + } else if !known { + // Endpoint IDs don't identify a model generation. Use the largest + // documented dimensions per ratio, with an explicit estimate flag. + for index, value := range arkVideo10Dimensions[resolution] { + if value.width*value.height > values[index].width*values[index].height { + values[index] = value + } + } + } + if !adaptive { + return values[ratioIndex], !known || defaulted, nil + } + largest := values[0] + for _, value := range values[1:] { + if value.width*value.height > largest.width*largest.height { + largest = value + } + } + return largest, true, nil +} + +func videoPixelDimensions(size string) (videoDimensions, bool) { + parts := strings.Split(strings.ReplaceAll(size, "×", "x"), "x") + if len(parts) != 2 { + return videoDimensions{}, false + } + w, ew := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64) + h, eh := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + return videoDimensions{w, h}, ew == nil && eh == nil && w > 0 && h > 0 +} + +// Generic video tiers use the short edge and requested aspect ratio. This is a +// platform pricing convention for non-Seedance sizes, not a provider pixel claim. +func videoScaledDimensions(resolution, ratio string) (videoDimensions, bool, error) { + raw := strings.TrimSuffix(strings.TrimSuffix(resolution, "横"), "竖") + edge, err := strconv.ParseInt(strings.TrimSuffix(raw, "p"), 10, 64) + if err != nil || edge <= 0 { + return videoDimensions{}, false, BadAuthRequest("视频 Token 计费需要有效分辨率或明确的宽×高尺寸") + } + if ratio == "auto" || ratio == "adaptive" { + ratio = "16:9" + } + parts := strings.Split(ratio, ":") + if len(parts) != 2 { + return videoDimensions{}, false, BadAuthRequest("视频 Token 计费需要有效画幅比例") + } + w, ew := strconv.ParseInt(parts[0], 10, 64) + h, eh := strconv.ParseInt(parts[1], 10, 64) + if ew != nil || eh != nil || w <= 0 || h <= 0 || edge > (math.MaxInt64-min(w, h))/max(w, h) { + return videoDimensions{}, false, BadAuthRequest("视频画幅超出 Token 计算范围") + } + longEdge := (edge*max(w, h) + min(w, h) - 1) / min(w, h) + longEdge = (longEdge + 1) / 2 * 2 + if w >= h { + return videoDimensions{longEdge, edge}, true, nil + } + return videoDimensions{edge, longEdge}, true, nil +} diff --git a/backend/internal/app/video_token_billing_test.go b/backend/internal/app/video_token_billing_test.go new file mode 100644 index 000000000..c52e8670c --- /dev/null +++ b/backend/internal/app/video_token_billing_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "math" + "testing" +) + +func TestArkVideoEstimateUsesOfficialDimensionsAndNoExtraFrames(t *testing.T) { + for _, tt := range []struct { + name, model, resolution, ratio string + width, height int64 + estimated bool + }{ + {"1.0 fast retains 1080", "doubao-seedance-1-0-pro-fast", "1080P", "16:9", 1920, 1088, false}, + {"1.5", "doubao-seedance-1-5-pro", "720", "16:9", 1280, 720, false}, + {"2.5", "doubao-seedance-2-5", "480p", "9:16", 480, 854, false}, + {"4K widescreen", "doubao-seedance-2-0", "4K", "21:9", 4398, 1886, false}, + {"4K classic", "doubao-seedance-2-0", "2160p", "4:3", 3326, 2494, false}, + {"endpoint conservative size", "ep-custom", "720p", "21:9", 1504, 640, true}, + {"adaptive", "doubao-seedance-2-0", "720p", "adaptive", 1112, 834, true}, + } { + t.Run(tt.name, func(t *testing.T) { + estimate := estimateArkVideoTokens(map[string]any{"config": map[string]any{ + "model": "display-name-fast", "providerModelKey": tt.model, "vquality": tt.resolution, "size": tt.ratio, "videoSeconds": "5", + }}) + if estimate.Err != nil { + t.Fatal(estimate.Err) + } + got := estimate.Video + want := (tt.width*tt.height*24*5 + 1023) / 1024 + if got.OutputWidth != tt.width || got.OutputHeight != tt.height || got.FormulaTokens != want || got.DimensionsEstimated != tt.estimated || got.ReservedTokens != (want*110+99)/100 { + t.Fatalf("estimate=%#v, want %dx%d, tokens=%d", got, tt.width, tt.height, want) + } + }) + } +} + +func TestArkVideoEstimateRejectsInvalidParameters(t *testing.T) { + for _, patch := range []map[string]any{ + {"vquality": "garbage"}, {"size": "invalid"}, {"size": "0:7"}, + {"videoSeconds": "-1"}, {"videoSeconds": "1.5"}, {"videoSeconds": math.MaxInt64}, + } { + config := map[string]any{"model": "doubao-seedance-2-0", "vquality": "720p", "size": "16:9", "videoSeconds": "5"} + for k, v := range patch { + config[k] = v + } + if got := estimateArkVideoTokens(map[string]any{"config": config}); got.Err == nil || got.OutputTokens != 0 { + t.Fatalf("invalid config %#v yielded %#v", patch, got) + } + } +} + +func TestArkVideoReferenceUsesOutputPixelsAndFractionalSeconds(t *testing.T) { + input := map[string]any{"config": map[string]any{"model": "doubao-seedance-2-0", "videoSeconds": "5", "vquality": "720p", "size": "16:9"}, + "referenceVideos": []any{map[string]any{"durationMs": 1250, "width": 3840, "height": 2160}, map[string]any{"durationMs": 250}}, + } + got := estimateArkVideoTokens(input) + if got.Err != nil || got.Video.FormulaTokens != 140400 || got.Video.ReferenceSeconds != 1.5 || got.Video.ReferenceDurationEstimated { + t.Fatalf("estimate=%#v error=%v", got.Video, got.Err) + } + for _, references := range []any{[]any{map[string]any{"durationMs": -1}}, []any{map[string]any{"durationMs": 1.5}}, []any{map[string]any{"durationMs": int64(math.MaxInt64)}, map[string]any{"durationMs": 6000}}, "invalid", make([]any, 129)} { + input["referenceVideos"] = references + if got := estimateArkVideoTokens(input); got.Err == nil { + t.Fatalf("accepted invalid references: %#v", references) + } + } +} diff --git a/backend/internal/app/video_token_quote_test.go b/backend/internal/app/video_token_quote_test.go new file mode 100644 index 000000000..9e9f6aa5c --- /dev/null +++ b/backend/internal/app/video_token_quote_test.go @@ -0,0 +1,221 @@ +package app + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func TestVideoTokenChannelQuoteAgreesWithTaskBilling(t *testing.T) { + for _, test := range []struct { + name string + audio any + free bool + wantTier string + wantPrice int64 + wantCost int64 + }{ + {name: "audio default", wantTier: "video-audio", wantPrice: 16_000_000, wantCost: 1_900_800}, + {name: "explicit audio", audio: true, wantTier: "video-audio", wantPrice: 16_000_000, wantCost: 1_900_800}, + {name: "silent", audio: false, wantTier: "video-silent", wantPrice: 8_000_000, wantCost: 950_400}, + {name: "configured free", audio: true, free: true, wantTier: "video-audio"}, + } { + t.Run(test.name, func(t *testing.T) { + svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t) + if test.free { + if err := db.Model(&model.ChannelModelPriceTier{}).Where("id = ?", test.wantTier).Update("output_token_price_microcredits", 0).Error; err != nil { + t.Fatal(err) + } + } + intent := ModelRequestIntent{Capability: "video", Options: map[string]any{ + "vquality": "720p", "size": "16:9", "videoSeconds": 5, + }} + if test.audio != nil { + intent.Options["videoGenerateAudio"] = test.audio + } + quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent}) + if err != nil { + t.Fatalf("QuoteChannelModel() error = %v", err) + } + assertVideoTokenQuote(t, quote, test.wantCost) + assertVideoQuoteHasNoFinancialWrites(t, db) + + input := quoteInput(intent, channelModel.ModelKey) + input["config"].(map[string]any)["channelId"] = channel.ID + resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_video", "") + if err != nil { + t.Fatalf("resolveSystemChannelModelSelection() error = %v", err) + } + order, err := svc.taskBillingOrder("quote-user", &model.Task{ID: "video-task", Type: "canvas_video"}, resolved) + if err != nil { + t.Fatalf("taskBillingOrder() error = %v", err) + } + if order == nil || order.PriceTierID != test.wantTier || order.OutputTokenPriceMicrocredits != test.wantPrice || order.InputTokenPriceMicrocredits != 0 || order.CachedTokenPriceMicrocredits != 0 { + t.Fatalf("wrong video price tier: %#v", order) + } + if order.AmountMicrocredits != quote.AmountMicrocredits || order.ReservedAmountMicrocredits != quote.AmountMicrocredits || order.Quantity != quote.Quantity { + t.Fatalf("quote and task reservation disagree: quote=%#v order=%#v", quote, order) + } + assertVideoQuoteHasNoFinancialWrites(t, db) + }) + } +} + +func TestVideoTokenLogicalQuoteAgreesWithSystemChannel(t *testing.T) { + for _, policy := range []string{"channel", "unified"} { + t.Run(policy, func(t *testing.T) { + svc, db, channel, channelModel, profile := newVideoTokenQuoteFixture(t) + spec, err := CapabilitySpecFromModelCapabilityConfig(profile, "video") + if err != nil { + t.Fatal(err) + } + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + logical := model.LogicalModel{ + ID: "logical-video", Code: "seedance-video", Capability: "video", Enabled: true, + ActiveRevisionID: "video-revision", PricePolicy: policy, BillingMode: "token", OutputPriceMicrocredits: 16_000_000, + } + revision := model.LogicalModelRevision{ + ID: logical.ActiveRevisionID, LogicalModelID: logical.ID, Version: 1, + CapabilitySpecJSON: string(specJSON), + DefaultOptionsJSON: `{"vquality":"720p","size":"16:9","videoSeconds":5,"videoGenerateAudio":true}`, + } + route := model.LogicalModelRoute{ + ID: "video-route", LogicalModelRevisionID: revision.ID, ChannelModelID: channelModel.ID, Enabled: true, Weight: 1, + } + for _, row := range []any{&logical, &revision, &route} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + intent := ModelRequestIntent{Capability: "video"} + logicalQuote, err := svc.QuoteLogicalModel(logical.ID, intent) + if err != nil { + t.Fatalf("QuoteLogicalModel() error = %v", err) + } + assertVideoTokenQuote(t, logicalQuote, 1_900_800) + if logicalQuote.LogicalModelID != logical.ID { + t.Fatalf("logicalModelId = %q, want %q", logicalQuote.LogicalModelID, logical.ID) + } + channelQuote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: intent}) + if err != nil { + t.Fatal(err) + } + if logicalQuote.AmountMicrocredits != channelQuote.AmountMicrocredits || logicalQuote.Quantity != channelQuote.Quantity || !reflect.DeepEqual(logicalQuote.VideoTokenEstimate, channelQuote.VideoTokenEstimate) { + t.Fatalf("logical and channel quote disagree: logical=%#v channel=%#v", logicalQuote, channelQuote) + } + assertVideoQuoteHasNoFinancialWrites(t, db) + }) + } +} + +func TestVideoTokenChannelQuoteRejectsUnavailableChannel(t *testing.T) { + for _, test := range []struct { + name string + field string + value any + }{ + {name: "disabled channel", field: "enabled", value: false}, + {name: "private channel", field: "scope", value: model.ChannelScopeUser}, + {name: "disabled model", field: "enabled", value: false}, + } { + t.Run(test.name, func(t *testing.T) { + svc, db, channel, channelModel, _ := newVideoTokenQuoteFixture(t) + row, id := any(&model.ModelChannel{}), channel.ID + if test.name == "disabled model" { + row, id = &model.ChannelModel{}, channelModel.ID + } + if err := db.Model(row).Where("id = ?", id).Update(test.field, test.value).Error; err != nil { + t.Fatal(err) + } + quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: channel.ID, ModelKey: channelModel.ModelKey, Intent: ModelRequestIntent{Capability: "video"}}) + if err == nil || quote != nil { + t.Fatalf("unavailable channel/model must be rejected: quote=%#v error=%v", quote, err) + } + assertVideoQuoteHasNoFinancialWrites(t, db) + }) + } +} + +func TestVideoTokenQuoteRejectsInvalidReferenceCounts(t *testing.T) { + for _, count := range []int{-1, 129, int(^uint(0) >> 1)} { + for _, kind := range []string{"image", "video", "audio"} { + t.Run(fmt.Sprintf("%s/%d", kind, count), func(t *testing.T) { + intent := ModelRequestIntent{Capability: "video", Inputs: map[string]int{kind: count}} + // A nil repository verifies counts fail before allocation, lookup, or routing. + svc := &Service{} + if quote, err := svc.QuoteChannelModel(ChannelModelQuoteRequest{ChannelID: "system-video", ModelKey: "video", Intent: intent}); err == nil || quote != nil { + t.Fatalf("channel quote must reject invalid count: quote=%#v error=%v", quote, err) + } + if quote, err := svc.QuoteLogicalModel("logical-video", intent); err == nil || quote != nil { + t.Fatalf("logical quote must reject invalid count: quote=%#v error=%v", quote, err) + } + }) + } + } +} + +func newVideoTokenQuoteFixture(t *testing.T) (*Service, *gorm.DB, model.ModelChannel, model.ChannelModel, *ModelCapabilityConfig) { + t.Helper() + const providerModel = "doubao-seedance-1-5-pro-251215" + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), providerModel) + profile.Video.Duration.Default = 5 + tiers := []model.ChannelModelPriceTier{ + {ID: "video-audio", SelectorKey: `{"videoGenerateAudio":"true"}`, SelectorJSON: `{"videoGenerateAudio":"true"}`, ProviderModelKey: providerModel, BillingMode: "token", OutputTokenPriceMicrocredits: 16_000_000, PriceConfigured: true, Enabled: true}, + {ID: "video-silent", SelectorKey: `{"videoGenerateAudio":"false"}`, SelectorJSON: `{"videoGenerateAudio":"false"}`, ProviderModelKey: providerModel, BillingMode: "token", OutputTokenPriceMicrocredits: 8_000_000, PriceConfigured: true, Enabled: true}, + } + svc, db, channel, channelModel := createSystemChannelSelectionFixture(t, "video", model.ChannelInterfaceVolcengineArkVideo, profile, tiers) + channelModel.ProviderModelKey = providerModel + if err := db.Model(&model.ChannelModel{}).Where("id = ?", channelModel.ID).Update("provider_model_key", providerModel).Error; err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.SystemSetting{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + account := model.CreditAccount{UserID: "quote-user", AvailableMicrocredits: 10_000_000, ReservedMicrocredits: 250_000, Version: 7} + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + return svc, db, channel, channelModel, profile +} + +func assertVideoTokenQuote(t *testing.T, quote *LogicalModelQuote, amount int64) { + t.Helper() + if quote == nil || quote.BillingMode != "token" || !quote.Estimated || quote.Quantity != 118_800 || quote.AmountMicrocredits != amount { + t.Fatalf("unexpected video quote: %#v, want 118800 tokens and %d microcredits", quote, amount) + } + want := VideoTokenEstimate{ + FormulaTokens: 108_000, ReservedTokens: 118_800, OutputWidth: 1280, OutputHeight: 720, + FramesPerSecond: 24, OutputSeconds: 5, ReservationMarginPercent: 10, + } + if quote.VideoTokenEstimate == nil || *quote.VideoTokenEstimate != want { + t.Fatalf("video estimate = %#v, want %#v", quote.VideoTokenEstimate, want) + } +} + +func assertVideoQuoteHasNoFinancialWrites(t *testing.T, db *gorm.DB) { + t.Helper() + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "quote-user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 10_000_000 || account.ReservedMicrocredits != 250_000 || account.Version != 7 { + t.Fatalf("read-only quote changed the credit account: %#v", account) + } + for _, row := range []any{&model.BillingOrder{}, &model.CreditLedgerEntry{}, &model.Task{}} { + var count int64 + if err := db.Model(row).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("read-only quote created %d records of %T", count, row) + } + } +} diff --git a/backend/internal/app/video_token_tier_test.go b/backend/internal/app/video_token_tier_test.go new file mode 100644 index 000000000..babe90919 --- /dev/null +++ b/backend/internal/app/video_token_tier_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestNormalizeVideoAudioPriceSelector(t *testing.T) { + for _, test := range []struct { + value string + want string + }{ + {value: "true", want: "true"}, + {value: "false", want: "false"}, + {value: " FALSE ", want: "false"}, + {value: "*", want: ""}, + } { + t.Run(test.value, func(t *testing.T) { + selector, _, _, err := normalizeChannelModelTierSelector("video", ChannelModelPriceTierRequest{ + Selector: map[string]string{"videoGenerateAudio": test.value}, + }) + if err != nil || selector["videoGenerateAudio"] != test.want { + t.Fatalf("selector = %#v, error = %v, want audio %q", selector, err, test.want) + } + if test.want == "" && len(selector) != 0 { + t.Fatalf("wildcard must share the default selector, got %#v", selector) + } + }) + } + for _, value := range []string{"yes", "1", "0", "auto"} { + if _, _, _, err := normalizeChannelModelTierSelector("video", ChannelModelPriceTierRequest{ + Selector: map[string]string{"videoGenerateAudio": value}, + }); err == nil { + t.Fatalf("invalid audio selector %q accepted", value) + } + } + for _, capability := range []string{"text", "image", "audio"} { + for _, value := range []string{"true", "false", "*"} { + if _, _, _, err := normalizeChannelModelTierSelector(capability, ChannelModelPriceTierRequest{ + Selector: map[string]string{"videoGenerateAudio": value}, + }); err == nil { + t.Fatalf("%s model accepted video audio selector %q", capability, value) + } + } + } +} + +func TestVideoPriceTierMatchesAudioAndActualReferences(t *testing.T) { + channelModel := model.ChannelModel{PriceTiers: []model.ChannelModelPriceTier{ + {ID: "default", SelectorJSON: `{}`, Enabled: true, PriceConfigured: true}, + {ID: "reference", SelectorJSON: `{"operation":"video_to_video"}`, Enabled: true, PriceConfigured: true}, + {ID: "reference-audio", SelectorJSON: `{"operation":"video_to_video","videoGenerateAudio":"true"}`, Enabled: true, PriceConfigured: true}, + {ID: "reference-silent", SelectorJSON: `{"operation":"video_to_video","videoGenerateAudio":"false"}`, Enabled: true, PriceConfigured: true}, + }} + for _, test := range []struct { + name string + value any + want string + }{ + {name: "audio boolean", value: true, want: "reference-audio"}, + {name: "silent boolean", value: false, want: "reference-silent"}, + {name: "audio string", value: "true", want: "reference-audio"}, + {name: "silent string", value: "false", want: "reference-silent"}, + {name: "unspecified", want: "reference"}, + {name: "invalid is not silent", value: "invalid", want: "reference"}, + } { + t.Run(test.name, func(t *testing.T) { + options := map[string]any{} + if test.value != nil { + options["videoGenerateAudio"] = test.value + } + for _, source := range []string{"config", "capabilityOptions"} { + intent := ModelRequestIntentFromTaskInput(map[string]any{ + "mode": "video", "referenceVideos": []any{map[string]any{"id": "video-reference"}}, source: options, + }, "canvas_video", "reference_to_video") + tier := channelModelPriceTierForIntent(channelModel, intent) + if tier == nil || tier.ID != test.want { + t.Fatalf("%s matched %#v, want %s", source, tier, test.want) + } + } + }) + } + intent := ModelRequestIntent{Capability: "video", Inputs: map[string]int{"video": 1}} + intent.Options = mergeIntentDefaults(map[string]any{"videoGenerateAudio": false}, map[string]any{"videoGenerateAudio": true}) + if tier := channelModelPriceTierForIntent(channelModel, intent); tier == nil || tier.ID != "reference-silent" { + t.Fatalf("explicit false must override audio default: %#v", tier) + } +} + +func TestVideoTokenPricesUseOutputOnlyAndSharedBounds(t *testing.T) { + for _, protocol := range []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineArkAgentPlanVideo, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceType("newapi-channel-2"), model.ChannelInterfaceType("custom-video")} { + for _, test := range []struct { + name string + input, output int64 + cached int64 + valid bool + }{ + {name: "free", valid: true}, + {name: "paid", output: 16_000_000, valid: true}, + {name: "maximum", output: maxChannelModelTokenPriceMicrocredits, valid: true}, + {name: "over maximum", output: maxChannelModelTokenPriceMicrocredits + 1}, + {name: "negative", output: -1}, + {name: "hidden input price", input: 1, output: 16_000_000}, + {name: "hidden cached price", output: 16_000_000, cached: 1}, + } { + t.Run(string(protocol)+"/"+test.name, func(t *testing.T) { + if got := ValidateChannelModelPrice("token", "video", protocol, 0, test.input, test.output, test.cached); got != test.valid { + t.Fatalf("price validity = %v, want %v", got, test.valid) + } + for _, configured := range []bool{true, false} { + err := validateChannelModelTierPricing("video", protocol, "token", ChannelModelPriceTierRequest{ + PriceConfigured: configured, InputTokenPriceMicrocredits: test.input, + OutputTokenPriceMicrocredits: test.output, CachedTokenPriceMicrocredits: test.cached, + }) + if (err == nil) != test.valid { + t.Fatalf("configured=%v error=%v, want validity %v", configured, err, test.valid) + } + } + }) + } + } +} diff --git a/backend/internal/app/video_usage_test.go b/backend/internal/app/video_usage_test.go new file mode 100644 index 000000000..7c8ff67bb --- /dev/null +++ b/backend/internal/app/video_usage_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "fmt" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestArkVideoUsageRequiresValidFinalCompletionTokens(t *testing.T) { + for _, path := range []string{"/api/v3/contents/generations/tasks/video-1", "/api/plan/v3/contents/generations/tasks/video-1"} { + for _, test := range []struct { + name string + payload string + want int64 + }{ + {name: "completion", payload: `{"status":"succeeded","usage":{"completion_tokens":108900}}`, want: 108900}, + {name: "completion precedes output and total", payload: `{"usage":{"completion_tokens":108900,"output_tokens":900000,"total_tokens":200000}}`, want: 108900}, + {name: "total when completion missing", payload: `{"usage":{"total_tokens":35800}}`, want: 35800}, + {name: "total precedes output alias", payload: `{"usage":{"output_tokens":900000,"total_tokens":35800}}`, want: 35800}, + {name: "nested response", payload: `{"data":{"status":"succeeded","usage":{"completion_tokens":35800}}}`, want: 35800}, + {name: "integer beyond float precision", payload: `{"usage":{"completion_tokens":9007199254740993}}`, want: 9007199254740993}, + {name: "largest int64", payload: `{"usage":{"completion_tokens":9223372036854775807}}`, want: 9223372036854775807}, + {name: "processing", payload: `{"status":"running","usage":{"completion_tokens":108900}}`}, + {name: "queued", payload: `{"status":"queued","usage":{"completion_tokens":108900}}`}, + {name: "failed", payload: `{"status":"failed","usage":{"completion_tokens":108900}}`}, + {name: "invalid status", payload: `{"status":true,"usage":{"completion_tokens":108900}}`}, + {name: "missing usage", payload: `{"status":"succeeded"}`}, + {name: "empty usage", payload: `{"status":"succeeded","usage":{}}`}, + {name: "output alias only", payload: `{"usage":{"output_tokens":108900}}`}, + {name: "unrelated usage metadata", payload: `{"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":108900,"cachedContentTokenCount":1}}`}, + } { + t.Run(path+"/"+test.name, func(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: path, InputTokens: 1, CachedTokens: 1, OutputTokens: 1, UsageAvailable: true} + (&Service{}).EnrichAPICallLog(log, []byte(test.payload)) + if log.InputTokens != 0 || log.CachedTokens != 0 || log.OutputTokens != test.want || log.UsageAvailable != (test.want > 0) { + t.Fatalf("usage = input:%d cached:%d output:%d available:%v, want output:%d", log.InputTokens, log.CachedTokens, log.OutputTokens, log.UsageAvailable, test.want) + } + }) + } + } +} + +func TestArkVideoUsageDoesNotMaskMalformedCompletionWithTotal(t *testing.T) { + for _, invalid := range []string{"0", "-1", "1.5", "1.0", `"108900"`, "9223372036854775808", "1e100", "null", "true", "{}"} { + for _, field := range []string{"completion_tokens", "total_tokens"} { + t.Run(field+"/"+invalid, func(t *testing.T) { + usage := fmt.Sprintf(`{"%s":%s}`, field, invalid) + if field == "completion_tokens" { + usage = fmt.Sprintf(`{"completion_tokens":%s,"total_tokens":108900}`, invalid) + } + log := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/video-1"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"status":"succeeded","usage":`+usage+`}`)) + if log.UsageAvailable || log.OutputTokens != 0 { + t.Fatalf("accepted invalid usage %s: output:%d available:%v", usage, log.OutputTokens, log.UsageAvailable) + } + }) + } + } +} + +func TestArkVideoUsageNeverAddsInputOrCachedTokens(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/video-1"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"status":"succeeded","usage":{"completion_tokens":108900,"input_tokens":999,"cached_tokens":50,"prompt_tokens_details":{"cached_tokens":100}},"usageMetadata":{"promptTokenCount":888,"candidatesTokenCount":777,"cachedContentTokenCount":200}}`)) + if !log.UsageAvailable || log.InputTokens != 0 || log.CachedTokens != 0 || log.OutputTokens != 108900 { + t.Fatalf("video usage was contaminated by text usage: %#v", log) + } +} + +func TestGenericVideoUsageRequiresPositiveFinalIntegerTokens(t *testing.T) { + for _, test := range []struct { + payload string + want int64 + }{ + {`{"data":{"status":"completed","usage":{"completion_tokens":108000}}}`, 108000}, + {`{"status":"SUCCESS","usage":{"output_tokens":100000,"input_tokens":20}}`, 100000}, + {`{"status":"succeeded","usage":{"total_tokens":90000}}`, 90000}, + {`{"status":"running","usage":{"completion_tokens":108000}}`, 0}, + {`{"status":"failed","usage":{"output_tokens":108000}}`, 0}, + {`{"usage":{"completion_tokens":1.5,"total_tokens":108000}}`, 0}, + {`{"usage":{"output_tokens":-1,"total_tokens":108000}}`, 0}, + {`{"usage":{"input_tokens":108000}}`, 0}, + } { + log := &model.ApiCallLog{Capability: "video", Path: "/v1/video/generations/task-1"} + (&Service{}).EnrichAPICallLog(log, []byte(test.payload)) + if log.OutputTokens != test.want || log.UsageAvailable != (test.want > 0) || log.InputTokens != 0 || log.CachedTokens != 0 { + t.Fatalf("payload=%s log=%#v", test.payload, log) + } + } +} diff --git a/backend/internal/database/channel_model_description_test.go b/backend/internal/database/channel_model_description_test.go new file mode 100644 index 000000000..5f5843bde --- /dev/null +++ b/backend/internal/database/channel_model_description_test.go @@ -0,0 +1,40 @@ +package database + +import "testing" + +func TestChannelModelDescriptionMigration(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE channel_models (id TEXT PRIMARY KEY, model_key TEXT, channel_label TEXT, unit_price_microcredits INTEGER)`, + `INSERT INTO channel_models VALUES ('model-a', 'seedance-2.0', '优惠渠道-993', 300000)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelModelDescription(db); err != nil { + t.Fatal(err) + } + } + var row struct { + ModelKey, ChannelLabel, Description string + UnitPriceMicrocredits int64 + } + if err := db.Table("channel_models").Where("id = ?", "model-a").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ModelKey != "seedance-2.0" || row.ChannelLabel != "优惠渠道-993" || row.UnitPriceMicrocredits != 300000 || row.Description != "" { + t.Fatalf("migration changed existing data: %#v", row) + } + if err := db.Exec(`INSERT INTO channel_models (id) VALUES ('model-b')`).Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Table("channel_models").Where("description = ''").Count(&count).Error; err != nil || count != 2 { + t.Fatalf("description default missing: count=%d err=%v", count, err) + } +} diff --git a/backend/internal/database/channel_model_label_test.go b/backend/internal/database/channel_model_label_test.go new file mode 100644 index 000000000..d38c87103 --- /dev/null +++ b/backend/internal/database/channel_model_label_test.go @@ -0,0 +1,40 @@ +package database + +import "testing" + +func TestChannelModelLabelMigrationPreservesIdentityAndPricing(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE channel_models (id TEXT PRIMARY KEY, channel_id TEXT, model_key TEXT, provider_model_key TEXT, price_version INTEGER, unit_price_microcredits INTEGER)`, + `INSERT INTO channel_models VALUES ('model-a', 'channel-a', 'seedance-2.0', 'upstream-v2', 7, 300000)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelModelLabel(db); err != nil { + t.Fatal(err) + } + } + var row struct { + ID, ChannelID, ModelKey, ProviderModelKey, ChannelLabel string + PriceVersion, UnitPriceMicrocredits int64 + } + if err := db.Table("channel_models").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ID != "model-a" || row.ChannelID != "channel-a" || row.ModelKey != "seedance-2.0" || row.ProviderModelKey != "upstream-v2" || row.ChannelLabel != "" || row.PriceVersion != 7 || row.UnitPriceMicrocredits != 300000 { + t.Fatalf("migration changed existing identity/pricing: %#v", row) + } + if err := db.Exec(`INSERT INTO channel_models (id) VALUES ('model-b')`).Error; err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Table("channel_models").Where("channel_label = ''").Count(&count).Error; err != nil || count != 2 { + t.Fatalf("empty label default missing: count=%d err=%v", count, err) + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index d3837796c..c2d0cefec 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 23 +const CurrentSchemaVersion int64 = 26 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -89,6 +89,34 @@ var schemaMigrations = []migration{ {version: 23, name: "canvas_revision_history", checksum: "sha256:canvas-revision-history-v23-20260918", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CanvasProject{}, &model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}) }}, + {version: 24, name: "channel_model_label", checksum: "sha256:channel-model-label-v24", apply: migrateChannelModelLabel}, + {version: 25, name: "video_token_formula_snapshot", checksum: "sha256:video-token-formula-snapshot-v25", apply: migrateVideoTokenFormulaSnapshot}, + {version: 26, name: "channel_model_description", checksum: "sha256:channel-model-description-v26", apply: migrateChannelModelDescription}, +} + +func migrateChannelModelDescription(tx *gorm.DB) error { + if tx.Migrator().HasColumn(&model.ChannelModel{}, "Description") { + return nil + } + return tx.Migrator().AddColumn(&model.ChannelModel{}, "Description") +} + +func migrateVideoTokenFormulaSnapshot(tx *gorm.DB) error { + for _, field := range []string{"VideoFormulaTokens", "UsageSource"} { + if !tx.Migrator().HasColumn(&model.BillingOrder{}, field) { + if err := tx.Migrator().AddColumn(&model.BillingOrder{}, field); err != nil { + return fmt.Errorf("增加视频 Token 结算字段 %s:%w", field, err) + } + } + } + return nil +} + +func migrateChannelModelLabel(tx *gorm.DB) error { + if tx.Migrator().HasColumn(&model.ChannelModel{}, "ChannelLabel") { + return nil + } + return tx.Migrator().AddColumn(&model.ChannelModel{}, "ChannelLabel") } func migrateSchemaV14(tx *gorm.DB) error { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 21f869dec..a375458e8 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -446,7 +446,7 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil { + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.BillingOrder{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { @@ -490,7 +490,7 @@ func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}); err != nil { + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}, &model.BillingOrder{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { diff --git a/backend/internal/database/migrations_video_token_test.go b/backend/internal/database/migrations_video_token_test.go new file mode 100644 index 000000000..5323e95da --- /dev/null +++ b/backend/internal/database/migrations_video_token_test.go @@ -0,0 +1,44 @@ +package database + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestMigrateSchemaV25PreservesHistoricalBillingOrders(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-video-token-formula-v25?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + for _, field := range []string{"VideoFormulaTokens", "UsageSource"} { + if err := db.Migrator().DropColumn(&model.BillingOrder{}, field); err != nil { + t.Fatal(err) + } + } + if err := db.Exec(`INSERT INTO billing_orders (id, user_id, idempotency_key, capability, billing_mode, quantity, amount_microcredits, output_tokens, usage_available, status) VALUES ('legacy', 'user', 'task', 'video', 'token', 118800, 1900800, 108000, true, 'settled')`).Error; err != nil { + t.Fatal(err) + } + if err := db.Where("version = ?", 25).Delete(&schemaMigration{}).Error; err != nil { + t.Fatal(err) + } + for range 2 { + if err := MigrateSchema(db); err != nil { + t.Fatalf("upgrade to v25: %v", err) + } + } + var order model.BillingOrder + if err := db.First(&order, "id = ?", "legacy").Error; err != nil { + t.Fatal(err) + } + if order.VideoFormulaTokens != 0 || order.UsageSource != "" || order.Quantity != 118800 || order.AmountMicrocredits != 1900800 || order.OutputTokens != 108000 || !order.UsageAvailable || order.Status != model.BillingStatusSettled { + t.Fatalf("historical billing changed during migration: %+v", order) + } + status, err := ReadSchemaStatus(db) + if err != nil || !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("schema not ready after upgrade: %+v, %v", status, err) + } +} diff --git a/backend/internal/handler/model_catalog.go b/backend/internal/handler/model_catalog.go index 507e433c1..b966540ed 100644 --- a/backend/internal/handler/model_catalog.go +++ b/backend/internal/handler/model_catalog.go @@ -11,6 +11,23 @@ import ( // RegisterModelCatalogRoutes 注册统一模型目录路由 func RegisterModelCatalogRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/model-catalog/quote", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + var req service.ChannelModelQuoteRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误")) + return + } + quote, err := svc.QuoteChannelModel(req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"quote": quote}) + }) r.GET("/model-catalog", func(c *gin.Context) { if _, err := currentUser(c, svc); err != nil { failService(c, err) diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 3ae4f43d1..4907872a9 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -22,9 +22,131 @@ tags: - name: resources - name: tasks - name: assets + - name: models - name: agent - name: admin paths: + /model-catalog: + get: + tags: [models] + summary: 获取创作端模型目录 + description: | + source=system 时读取 channels,source=frontend 时读取 models;两个集合始终为数组。 + 系统渠道模型按 capability + modelKey 分组展示,每条渠道选择及其价格保持独立。 + channelLabel 是后台渠道模型创建和更新时配置的公开渠道展示名,去除首尾空白后最多 80 字,空字符串表示使用渠道公开名称。 + description 是后台渠道模型创建和更新时填写的公开模型描述,去除首尾空白后最多 500 字;空字符串表示未填写。 + displayName 和 icon 用于产品模型一级菜单,channelLabel 不参与路由、权限或计费。 + responses: + "200": + description: 当前启用的模型目录 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + enum: [0] + msg: + type: string + data: + type: object + required: [source, models, channels] + properties: + source: + type: string + enum: [system, frontend] + models: + type: array + items: + type: object + channels: + type: array + items: + type: object + properties: + id: + type: string + displayName: + type: string + models: + type: array + items: + type: object + properties: + modelKey: + type: string + displayName: + type: string + icon: + type: string + channelLabel: + type: string + maxLength: 80 + default: "" + description: + type: string + maxLength: 500 + default: "" + "401": + $ref: "#/components/responses/Error" + /model-catalog/quote: + post: + tags: [models] + summary: 按当前参数预估系统渠道模型费用 + description: | + 需要登录会话 Cookie。仅接受启用的系统渠道和模型,采用服务端能力默认值及价格档选择规则。 + 不创建任务或账单、不冻结或扣除积分,也不调用模型供应商。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [channelId, modelKey, intent] + properties: + channelId: + type: string + minLength: 1 + modelKey: + type: string + minLength: 1 + description: 系统渠道中配置的模型键 + intent: + $ref: "#/components/schemas/ModelRequestIntent" + responses: + "200": + $ref: "#/components/responses/ModelQuote" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + /models/{id}/quote: + post: + tags: [models] + summary: 按当前参数预估前台模型费用 + description: | + 需要登录会话 Cookie。请求正文直接为创作意图,不使用 intent 外层包装。 + 服务端按前台模型能力、默认参数、供应线路和价格策略报价,不创建任务、账单或积分冻结。 + parameters: + - in: path + name: id + required: true + schema: { type: string } + description: 前台逻辑模型 ID + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModelRequestIntent" + responses: + "200": + $ref: "#/components/responses/ModelQuote" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" /canvas-projects/{id}: parameters: - in: path @@ -776,6 +898,119 @@ components: in: cookie name: open_ai_canvas_session schemas: + ModelRequestIntent: + type: object + required: [capability] + properties: + capability: + type: string + enum: [text, image, video, audio] + operation: + type: string + description: 可选生成操作,必须符合所选模型的能力合同 + inputs: + type: object + description: 按素材类型统计的参考数量,如 image、video、audio;另受所选模型的数量上限约束 + additionalProperties: + type: integer + minimum: 0 + maximum: 128 + options: + type: object + description: 当前创作规格;未指定的参数使用服务端默认值 + additionalProperties: true + example: + vquality: 720p + size: "16:9" + videoSeconds: 5 + videoGenerateAudio: true + LogicalModelQuote: + type: object + required: [logicalModelId, billingMode, quantity, amountMicrocredits, estimated] + properties: + logicalModelId: + type: string + description: 前台逻辑模型 ID;直接查询系统渠道模型时为空字符串 + billingMode: + type: string + enum: [fixed_request, per_second, token] + quantity: + type: integer + format: int64 + minimum: 1 + description: 按次为 1,按秒为生成秒数,按 Token 为预留用量(视频含平台预留余量) + amountMicrocredits: + type: integer + format: int64 + minimum: 0 + description: 预估预留微积分,1000000 微积分等于 1 积分;明确配置的免费价格返回 0 + estimated: + type: boolean + description: Token 计费为 true;优先有效上游 usage,视频无有效用量时按提交的公式快照结算,预留不构成费用上限 + videoTokenEstimate: + $ref: "#/components/schemas/VideoTokenEstimate" + VideoTokenEstimate: + type: object + description: 仅视频 Token 报价返回;将火山公式用量和平台预留余量分开披露 + required: [formulaTokens, reservedTokens, outputWidth, outputHeight, framesPerSecond, outputSeconds, referenceSeconds, referenceDurationEstimated, dimensionsEstimated, reservationMarginPercent] + properties: + formulaTokens: + type: integer + format: int64 + minimum: 1 + description: 按(参考视频总秒数 + 输出秒数)× 输出宽 × 输出高 × 帧率 / 1024 向上取整;上游最低计费用量可能更高 + reservedTokens: + type: integer + format: int64 + minimum: 1 + description: 公式用量叠加平台预留余量后向上取整 + outputWidth: + type: integer + format: int64 + minimum: 1 + outputHeight: + type: integer + format: int64 + minimum: 1 + framesPerSecond: + type: integer + format: int64 + minimum: 1 + outputSeconds: + type: integer + format: int64 + minimum: 1 + referenceSeconds: + type: number + format: double + minimum: 0 + description: 计入公式的参考视频总秒数;报价仅有参考数量时采用预估时长 + referenceDurationEstimated: + type: boolean + description: 参考时长未知、使用预估值时为 true + dimensionsEstimated: + type: boolean + description: 自适应画幅或上游型号无法确定,使用预估像素尺寸时为 true + reservationMarginPercent: + type: integer + format: int64 + minimum: 0 + description: 平台预留余量百分比,不属于火山官方计费公式 + ModelQuoteEnvelope: + allOf: + - $ref: "#/components/schemas/Envelope" + - type: object + required: [data] + properties: + code: + type: integer + enum: [0] + data: + type: object + required: [quote] + properties: + quote: + $ref: "#/components/schemas/LogicalModelQuote" AgentProfileRequest: type: object additionalProperties: false @@ -902,6 +1137,12 @@ components: - model_catalog_mismatch - invalid_model_selection responses: + ModelQuote: + description: 当前参数对应的费用报价,位于信封 data.quote + content: + application/json: + schema: + $ref: "#/components/schemas/ModelQuoteEnvelope" Envelope: description: 业务成功信封 content: diff --git a/backend/internal/kernel/token_billing.go b/backend/internal/kernel/token_billing.go new file mode 100644 index 000000000..c20babd2b --- /dev/null +++ b/backend/internal/kernel/token_billing.go @@ -0,0 +1,41 @@ +package kernel + +import ( + "errors" + "math/big" +) + +var ( + ErrInvalidTokenBilling = errors.New("invalid token billing parameters") + ErrTokenBillingOverflow = errors.New("token billing amount overflows int64") +) + +type TokenBillingTerm struct { + Tokens int64 + PriceMicrocredits int64 +} + +// TokenBillingAmount 按每百万 Token 单价及万分比倍率汇总费用,只对最终微积分金额上取整。 +func TokenBillingAmount(multiplierBPS int64, terms ...TokenBillingTerm) (int64, error) { + if multiplierBPS <= 0 { + return 0, ErrInvalidTokenBilling + } + var total, product big.Int + for _, term := range terms { + if term.Tokens < 0 || term.PriceMicrocredits < 0 { + return 0, ErrInvalidTokenBilling + } + product.Mul(big.NewInt(term.Tokens), big.NewInt(term.PriceMicrocredits)) + total.Add(&total, &product) + } + total.Mul(&total, big.NewInt(multiplierBPS)) + var remainder big.Int + total.QuoRem(&total, big.NewInt(10_000_000_000), &remainder) + if remainder.Sign() != 0 { + total.Add(&total, big.NewInt(1)) + } + if !total.IsInt64() { + return 0, ErrTokenBillingOverflow + } + return total.Int64(), nil +} diff --git a/backend/internal/kernel/token_billing_test.go b/backend/internal/kernel/token_billing_test.go new file mode 100644 index 000000000..7bab3ddb0 --- /dev/null +++ b/backend/internal/kernel/token_billing_test.go @@ -0,0 +1,90 @@ +package kernel + +import ( + "errors" + "math" + "testing" +) + +func TestTokenBillingAmount(t *testing.T) { + for _, test := range []struct { + name string + multiplier int64 + terms []TokenBillingTerm + want int64 + }{ + { + name: "maximum configured price", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: 10_000_000, PriceMicrocredits: 1_000_000_000_000}}, + want: 10_000_000_000_000, + }, + { + name: "maximum configured price with multiplier", multiplier: 12_500, + terms: []TokenBillingTerm{{Tokens: 10_000_000, PriceMicrocredits: 1_000_000_000_000}}, + want: 12_500_000_000_000, + }, + { + name: "round once after combining terms", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 400_000}, {Tokens: 1, PriceMicrocredits: 400_000}, {Tokens: 1, PriceMicrocredits: 100_000}}, + want: 1, + }, + { + name: "apply multiplier before rounding", multiplier: 12_500, + terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 400_000}, {Tokens: 1, PriceMicrocredits: 400_000}}, + want: 1, + }, + { + name: "fractional microcredit rounds up", multiplier: 1, + terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 1}}, + want: 1, + }, + { + name: "maximum int64 result after overflowing intermediate sum", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 500_000}, {Tokens: math.MaxInt64, PriceMicrocredits: 500_000}}, + want: math.MaxInt64, + }, + { + name: "free usage", multiplier: math.MaxInt64, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 0}}, + want: 0, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := TokenBillingAmount(test.multiplier, test.terms...) + if err != nil || amount != test.want { + t.Fatalf("TokenBillingAmount() = %d, %v; want %d", amount, err, test.want) + } + }) + } +} + +func TestTokenBillingAmountRejectsInvalidParametersAndFinalOverflow(t *testing.T) { + for _, test := range []struct { + name string + multiplier int64 + terms []TokenBillingTerm + wantErr error + }{ + {name: "zero multiplier", terms: []TokenBillingTerm{{Tokens: 1, PriceMicrocredits: 1}}, wantErr: ErrInvalidTokenBilling}, + {name: "negative multiplier", multiplier: -1, wantErr: ErrInvalidTokenBilling}, + {name: "negative tokens", multiplier: 10_000, terms: []TokenBillingTerm{{Tokens: -1}}, wantErr: ErrInvalidTokenBilling}, + {name: "negative price even with zero tokens", multiplier: 10_000, terms: []TokenBillingTerm{{PriceMicrocredits: -1}}, wantErr: ErrInvalidTokenBilling}, + { + name: "final amount overflow", multiplier: 10_001, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 1_000_000}}, + wantErr: ErrTokenBillingOverflow, + }, + { + name: "rounding causes final overflow", multiplier: 10_000, + terms: []TokenBillingTerm{{Tokens: math.MaxInt64, PriceMicrocredits: 1_000_000}, {Tokens: 1, PriceMicrocredits: 1}}, + wantErr: ErrTokenBillingOverflow, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := TokenBillingAmount(test.multiplier, test.terms...) + if amount != 0 || !errors.Is(err, test.wantErr) { + t.Fatalf("TokenBillingAmount() = %d, %v; want 0, %v", amount, err, test.wantErr) + } + }) + } +} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 609db0934..ff84157ee 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -42,6 +42,8 @@ type ChannelModel struct { ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` ProviderModelKey string `json:"providerModelKey" gorm:"size:120"` DisplayName string `json:"displayName" gorm:"size:160"` + ChannelLabel string `json:"channelLabel" gorm:"size:80;not null;default:''"` + Description string `json:"description" gorm:"size:500;not null;default:''"` SortOrder int `json:"sortOrder" gorm:"not null;default:0"` Icon string `json:"icon" gorm:"size:80"` Capability string `json:"capability" gorm:"size:32;index"` diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go index c5d7b2e6c..e85e6be18 100644 --- a/backend/internal/model/models_finance.go +++ b/backend/internal/model/models_finance.go @@ -54,26 +54,29 @@ type BillingOrder struct { AmountMicrocredits int64 `json:"amountMicrocredits"` ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` // ChargeLimitMicrocredits 非零时限制最终用户扣费;Agent 的 Token 报价用它把预授权金额固化为本轮硬上限。 - ChargeLimitMicrocredits int64 `json:"chargeLimitMicrocredits,omitempty"` - ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` - RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - Status BillingStatus `json:"status" gorm:"index;size:24"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - Error string `json:"error,omitempty" gorm:"size:1000"` - ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` - ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` - StartedAt *time.Time `json:"startedAt"` - SettledAt *time.Time `json:"settledAt"` - RefundedAt *time.Time `json:"refundedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt"` + ChargeLimitMicrocredits int64 `json:"chargeLimitMicrocredits,omitempty"` + ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` + RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + // VideoFormulaTokens 固化提交时的视频公式用量,不含预授权余量;旧订单保持 0,不反推历史用量。 + VideoFormulaTokens int64 `json:"videoFormulaTokens" gorm:"not null;default:0"` + UsageSource string `json:"usageSource,omitempty" gorm:"size:32;not null;default:''"` + UsageAvailable bool `json:"usageAvailable"` + Status BillingStatus `json:"status" gorm:"index;size:24"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + Error string `json:"error,omitempty" gorm:"size:1000"` + ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` + ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` + StartedAt *time.Time `json:"startedAt"` + SettledAt *time.Time `json:"settledAt"` + RefundedAt *time.Time `json:"refundedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` } type RedeemBatch struct { diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 24f10ddc5..db81f5a3c 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" "gorm.io/gorm" @@ -657,6 +658,29 @@ func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) { return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil } +const ( + billingUsageSourceProvider = "provider" + billingUsageSourceVideoFormula = "video_formula" +) + +func tokenSettlementUsage(db *gorm.DB, order model.BillingOrder) (*BillingUsage, string, error) { + usage, err := billingUsage(db, order.ID) + if err != nil && !errors.Is(err, ErrBillingUsageUnavailable) { + return nil, "", err + } + if order.Capability != "video" { + return usage, billingUsageSourceProvider, err + } + if err == nil && usage.OutputTokens > 0 && usage.InputTokens >= 0 && usage.CachedTokens >= 0 { + return &BillingUsage{OutputTokens: usage.OutputTokens}, billingUsageSourceProvider, nil + } + // 只有提交时明确记录的公式用量可以结算,预授权 Quantity 含余量,不能用作最终用量。 + if order.VideoFormulaTokens > 0 { + return &BillingUsage{OutputTokens: order.VideoFormulaTokens}, billingUsageSourceVideoFormula, nil + } + return usage, billingUsageSourceProvider, err +} + func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error { return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ "resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(), @@ -705,6 +729,7 @@ func (r *Repository) MarkBillingUncertain(id string, errorText string) error { func (r *Repository) SettleBillingOrder(id string, providerRequestID string) error { var observedUsage *BillingUsage + var observedUsageSource string var observedActual int64 observedActualAvailable := false err := r.db.Transaction(func(tx *gorm.DB) error { @@ -719,11 +744,12 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err return errors.New("billing order already refunded") } if order.BillingMode == "token" && !zeroPricedTokenOrder(order) { - usage, err := billingUsage(tx, id) + usage, usageSource, err := tokenSettlementUsage(tx, order) if err != nil { return err } observedUsage = usage + observedUsageSource = usageSource reserved := order.ReservedAmountMicrocredits if reserved <= 0 { reserved = order.AmountMicrocredits @@ -761,7 +787,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now, "actual_amount_microcredits": actual, "refunded_amount_microcredits": refund, "input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens, - "usage_available": true} + "usage_available": usageSource == billingUsageSourceProvider, "usage_source": usageSource} if providerRequestID != "" { updates["provider_request_id"] = providerRequestID } @@ -774,6 +800,9 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err } else if supplement > 0 { consumeNote = "Token 实际用量超过预授权,已补扣差额" } + if usageSource == billingUsageSourceVideoFormula { + consumeNote = strings.TrimSpace("按提交时的视频 Token 公式快照结算;" + consumeNote) + } if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume, AmountMicrocredits: -actual, AvailableDeltaMicrocredits: -supplement, ReservedDeltaMicrocredits: -reserved, AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, @@ -830,10 +859,11 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err }).Error }) if err != nil && observedUsage != nil { - // usage 是上游已经确认的事实;即使结算因账户状态异常回滚,也要保留给用户和管理员核对。 + // 即使账户状态异常导致回滚,也保留结算依据并标明来源,供用户和管理员核对。 updates := map[string]any{ "input_tokens": observedUsage.InputTokens, "output_tokens": observedUsage.OutputTokens, - "cached_tokens": observedUsage.CachedTokens, "usage_available": true, "updated_at": time.Now(), + "cached_tokens": observedUsage.CachedTokens, "usage_available": observedUsageSource == billingUsageSourceProvider, + "usage_source": observedUsageSource, "updated_at": time.Now(), } if observedActualAvailable { updates["actual_amount_microcredits"] = observedActual @@ -873,12 +903,13 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st actual := order.AmountMicrocredits var usage *BillingUsage + var usageSource string if order.BillingMode == "token" { if zeroPricedTokenOrder(order) { actual = 0 } else { var err error - usage, err = billingUsage(tx, id) + usage, usageSource, err = tokenSettlementUsage(tx, order) if err != nil { return err } @@ -888,6 +919,10 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st } } } + chargeCapped := order.BillingMode == "token" && order.ChargeLimitMicrocredits > 0 && actual > order.ChargeLimitMicrocredits + if chargeCapped { + actual = order.ChargeLimitMicrocredits + } if actual < 0 { return errors.New("invalid restored billing amount") } @@ -927,7 +962,8 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st orderUpdates["input_tokens"] = usage.InputTokens orderUpdates["output_tokens"] = usage.OutputTokens orderUpdates["cached_tokens"] = usage.CachedTokens - orderUpdates["usage_available"] = true + orderUpdates["usage_available"] = usageSource == billingUsageSourceProvider + orderUpdates["usage_source"] = usageSource } orderUpdate := tx.Model(&model.BillingOrder{}). Where("id = ? AND status = ?", order.ID, model.BillingStatusRefunded). @@ -939,6 +975,13 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st return ErrBillingStateConflict } + consumeNote := "人工查询确认上游成功,退款订单重新扣费" + if usageSource == billingUsageSourceVideoFormula { + consumeNote += ";按提交时的视频 Token 公式快照结算" + } + if chargeCapped { + consumeNote += ";已按本轮 Agent 硬上限结算" + } return tx.Create(&model.CreditLedgerEntry{ ID: newRepositoryID(), UserID: order.UserID, @@ -951,7 +994,7 @@ func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID st Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, - Note: "人工查询确认上游成功,退款订单重新扣费", + Note: consumeNote, }).Error }) } @@ -1020,38 +1063,28 @@ func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, err if usage == nil { return 0, ErrBillingUsageUnavailable } + if usage.InputTokens < 0 || usage.OutputTokens < 0 || usage.CachedTokens < 0 { + return 0, errors.New("invalid token usage amount") + } if order.Capability == "video" && usage.OutputTokens <= 0 { return 0, ErrBillingUsageUnavailable } - input := usage.InputTokens - usage.CachedTokens - if input < 0 { - input = 0 + terms := []kernel.TokenBillingTerm{ + {Tokens: usage.OutputTokens, PriceMicrocredits: order.OutputTokenPriceMicrocredits}, } - inputAmount, ok := safeTokenUsageProduct(input, order.InputTokenPriceMicrocredits) - if !ok { + // 视频 Token 总用量已由供应商 completion_tokens 表达,不再叠加文本输入和缓存费用。 + if order.Capability != "video" { + input := max(usage.InputTokens-usage.CachedTokens, 0) + terms = append(terms, + kernel.TokenBillingTerm{Tokens: input, PriceMicrocredits: order.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: usage.CachedTokens, PriceMicrocredits: order.CachedTokenPriceMicrocredits}, + ) + } + amount, err := kernel.TokenBillingAmount(order.MultiplierBasisPoints, terms...) + if err != nil { return 0, errors.New("invalid token usage amount") } - outputAmount, ok := safeTokenUsageProduct(usage.OutputTokens, order.OutputTokenPriceMicrocredits) - if !ok || inputAmount > 1<<63-1-outputAmount { - return 0, errors.New("invalid token usage amount") - } - cachedAmount, ok := safeTokenUsageProduct(usage.CachedTokens, order.CachedTokenPriceMicrocredits) - base := inputAmount + outputAmount - if !ok || base > 1<<63-1-cachedAmount { - return 0, errors.New("invalid token usage amount") - } - base += cachedAmount - if order.MultiplierBasisPoints <= 0 || base > (1<<63-1-9_999_999_999)/order.MultiplierBasisPoints { - return 0, errors.New("invalid token usage amount") - } - return (base*order.MultiplierBasisPoints + 9_999_999_999) / 10_000_000_000, nil -} - -func safeTokenUsageProduct(tokens int64, price int64) (int64, bool) { - if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) { - return 0, false - } - return tokens * price, true + return amount, nil } func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) { diff --git a/backend/internal/repository/finance_token_arithmetic_test.go b/backend/internal/repository/finance_token_arithmetic_test.go new file mode 100644 index 000000000..999407bdf --- /dev/null +++ b/backend/internal/repository/finance_token_arithmetic_test.go @@ -0,0 +1,94 @@ +package repository + +import ( + "math" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestTokenUsageAmountUsesExactArithmetic(t *testing.T) { + for _, test := range []struct { + name string + order model.BillingOrder + usage BillingUsage + want int64 + }{ + { + name: "video maximum price with multiplier ignores text charges", + order: model.BillingOrder{ + Capability: "video", MultiplierBasisPoints: 12_500, + InputTokenPriceMicrocredits: 1_000_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000, + CachedTokenPriceMicrocredits: 1_000_000_000_000, + }, + usage: BillingUsage{InputTokens: math.MaxInt64, OutputTokens: 10_000_000, CachedTokens: math.MaxInt64}, + want: 12_500_000_000_000, + }, + { + name: "text subtracts cache and preserves distinct prices", + order: model.BillingOrder{ + Capability: "text", MultiplierBasisPoints: 12_500, + InputTokenPriceMicrocredits: 500_000_000_000, OutputTokenPriceMicrocredits: 1_000_000_000_000, + CachedTokenPriceMicrocredits: 100_000_000_000, + }, + usage: BillingUsage{InputTokens: 20_000_000, OutputTokens: 10_000_000, CachedTokens: 5_000_000}, + want: 22_500_000_000_000, + }, + { + name: "round once after all text charges", + order: model.BillingOrder{ + Capability: "text", MultiplierBasisPoints: 10_000, + InputTokenPriceMicrocredits: 400_000, OutputTokenPriceMicrocredits: 400_000, CachedTokenPriceMicrocredits: 100_000, + }, + usage: BillingUsage{InputTokens: 2, OutputTokens: 1, CachedTokens: 1}, + want: 1, + }, + { + name: "maximum final amount", + order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_000, OutputTokenPriceMicrocredits: 1_000_000}, + usage: BillingUsage{OutputTokens: math.MaxInt64}, want: math.MaxInt64, + }, + } { + t.Run(test.name, func(t *testing.T) { + amount, err := tokenUsageAmount(test.order, &test.usage) + if err != nil || amount != test.want { + t.Fatalf("tokenUsageAmount() = %d, %v; want %d", amount, err, test.want) + } + }) + } +} + +func TestTokenUsageAmountRejectsInvalidArithmetic(t *testing.T) { + for _, test := range []struct { + name string + order model.BillingOrder + usage BillingUsage + }{ + { + name: "negative output price", + order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_000, OutputTokenPriceMicrocredits: -1}, + usage: BillingUsage{OutputTokens: 1}, + }, + { + name: "negative unused text cache price", + order: model.BillingOrder{Capability: "text", MultiplierBasisPoints: 10_000, CachedTokenPriceMicrocredits: -1}, + usage: BillingUsage{OutputTokens: 1}, + }, + { + name: "zero multiplier", + order: model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 1}, + usage: BillingUsage{OutputTokens: 1}, + }, + { + name: "final overflow", + order: model.BillingOrder{Capability: "video", MultiplierBasisPoints: 10_001, OutputTokenPriceMicrocredits: 1_000_000}, + usage: BillingUsage{OutputTokens: math.MaxInt64}, + }, + } { + t.Run(test.name, func(t *testing.T) { + if amount, err := tokenUsageAmount(test.order, &test.usage); amount != 0 || err == nil { + t.Fatalf("invalid usage billed %d, %v", amount, err) + } + }) + } +} diff --git a/backend/internal/repository/finance_video_formula_test.go b/backend/internal/repository/finance_video_formula_test.go new file mode 100644 index 000000000..f4dc99a1e --- /dev/null +++ b/backend/internal/repository/finance_video_formula_test.go @@ -0,0 +1,183 @@ +package repository + +import ( + "errors" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestVideoTokenSettlementChoosesProviderOrFormulaSnapshot(t *testing.T) { + for _, restore := range []bool{false, true} { + mode := "settle" + if restore { + mode = "restore" + } + for _, test := range []struct { + name string + log *model.ApiCallLog + formula int64 + cap int64 + wantTokens int64 + wantAmount int64 + wantSource string + wantError bool + }{ + {name: "missing usage", formula: 108000, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "zero usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "negative usage", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: -1}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "unavailable usage", formula: 108000, log: &model.ApiCallLog{OutputTokens: 200000}, wantTokens: 108000, wantAmount: 1728000, wantSource: "video_formula"}, + {name: "provider refund", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 100000, InputTokens: 20, CachedTokens: 10}, wantTokens: 100000, wantAmount: 1600000, wantSource: "provider"}, + {name: "provider supplement", formula: 108000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 3200000, wantSource: "provider"}, + {name: "provider cap", formula: 108000, cap: 1000000, log: &model.ApiCallLog{UsageAvailable: true, OutputTokens: 200000}, wantTokens: 200000, wantAmount: 1000000, wantSource: "provider"}, + {name: "formula cap", formula: 108000, cap: 1000000, wantTokens: 108000, wantAmount: 1000000, wantSource: "video_formula"}, + {name: "legacy no usage", wantError: true}, + {name: "invalid snapshot", formula: -1, wantError: true}, + } { + t.Run(mode+"/"+test.name, func(t *testing.T) { + repo, db := newRefundedBillingRecoveryRepository(t) + const available = int64(10000000) + const reserved = int64(1900800) + order := model.BillingOrder{ + ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", + Quantity: 118800, VideoFormulaTokens: test.formula, AmountMicrocredits: reserved, + ReservedAmountMicrocredits: reserved, OutputTokenPriceMicrocredits: 16000000, + MultiplierBasisPoints: 10000, ChargeLimitMicrocredits: test.cap, Status: model.BillingStatusRunning, + } + account := model.CreditAccount{UserID: "user", AvailableMicrocredits: available, ReservedMicrocredits: reserved} + settle := repo.SettleBillingOrder + if restore { + settle = repo.RestoreRefundedBillingOrder + order.Status = model.BillingStatusRefunded + order.RefundedAmountMicrocredits = reserved + account.ReservedMicrocredits = 0 + account.AvailableMicrocredits += reserved + } + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if test.log != nil { + log := *test.log + log.ID, log.BillingOrderID, log.Status = "log", "order", model.ApiCallStatusSucceeded + if err := db.Create(&log).Error; err != nil { + t.Fatal(err) + } + } + if err := settle("order", "provider-id"); test.wantError { + if !errors.Is(err, ErrBillingUsageUnavailable) { + t.Fatalf("expected unavailable usage, got %v", err) + } + var after model.CreditAccount + if err := db.First(&after, "user_id = ?", "user").Error; err != nil { + t.Fatal(err) + } + if after.AvailableMicrocredits != account.AvailableMicrocredits || after.ReservedMicrocredits != account.ReservedMicrocredits { + t.Fatalf("rejected settlement changed balance: %+v", after) + } + return + } else if err != nil { + t.Fatal(err) + } + if err := settle("order", "provider-id"); err != nil { + t.Fatalf("repeat settlement: %v", err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != test.wantAmount || order.OutputTokens != test.wantTokens || order.UsageSource != test.wantSource || order.UsageAvailable != (test.wantSource == "provider") || order.InputTokens != 0 || order.CachedTokens != 0 { + t.Fatalf("unexpected settled order: %+v", order) + } + if order.VideoFormulaTokens != test.formula || order.Quantity != 118800 || order.RefundedAmountMicrocredits != max(reserved-test.wantAmount, 0) { + t.Fatalf("snapshot or refund changed: %+v", order) + } + if err := db.First(&account, "user_id = ?", "user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != available+reserved-test.wantAmount || account.ReservedMicrocredits != 0 { + t.Fatalf("unexpected account after settlement: %+v", account) + } + var entries []model.CreditLedgerEntry + if err := db.Where("billing_order_id = ? AND type = ?", "order", model.CreditLedgerConsume).Find(&entries).Error; err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].AmountMicrocredits != -test.wantAmount { + t.Fatalf("settlement not idempotent: %+v", entries) + } + if test.wantSource == "video_formula" && !strings.Contains(entries[0].Note, "公式") { + t.Fatalf("formula source not recorded: %+v", entries[0]) + } + if test.cap > 0 && !strings.Contains(entries[0].Note, "硬上限") { + t.Fatalf("charge cap not recorded: %+v", entries[0]) + } + }) + } + } +} + +func TestVideoTokenSettlementDoesNotHideDatabaseErrors(t *testing.T) { + for _, restore := range []bool{false, true} { + repo, db := newRefundedBillingRecoveryRepository(t) + if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, AmountMicrocredits: 1900800, ReservedAmountMicrocredits: 1900800, OutputTokenPriceMicrocredits: 16000000, MultiplierBasisPoints: 10000, Status: model.BillingStatusRunning} + settle := repo.SettleBillingOrder + if restore { + order.Status = model.BillingStatusRefunded + settle = repo.RestoreRefundedBillingOrder + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := settle("order", ""); err == nil || errors.Is(err, ErrBillingUsageUnavailable) { + t.Fatalf("database failure was hidden: %v", err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if order.Status == model.BillingStatusSettled || order.UsageSource != "" || order.ActualAmountMicrocredits != 0 { + t.Fatalf("database failure changed settlement: %+v", order) + } + } +} + +func TestFreeVideoTokenSettlementDoesNotRequireUsage(t *testing.T) { + for _, restore := range []bool{false, true} { + repo, db := newRefundedBillingRecoveryRepository(t) + if err := db.Migrator().DropTable(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + order := model.BillingOrder{ID: "order", UserID: "user", IdempotencyKey: "task", Capability: "video", BillingMode: "token", VideoFormulaTokens: 108000, Status: model.BillingStatusRunning} + settle := repo.SettleBillingOrder + if restore { + order.Status = model.BillingStatusRefunded + settle = repo.RestoreRefundedBillingOrder + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CreditAccount{UserID: "user", AvailableMicrocredits: 123}).Error; err != nil { + t.Fatal(err) + } + if err := settle("order", ""); err != nil { + t.Fatal(err) + } + if err := db.First(&order, "id = ?", "order").Error; err != nil { + t.Fatal(err) + } + if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != 0 || order.UsageAvailable { + t.Fatalf("unexpected free settlement: %+v", order) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "user").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 123 || account.ReservedMicrocredits != 0 { + t.Fatalf("free order charged account: %+v", account) + } + } +} diff --git a/backend/internal/repository/finance_video_usage_test.go b/backend/internal/repository/finance_video_usage_test.go new file mode 100644 index 000000000..38736e188 --- /dev/null +++ b/backend/internal/repository/finance_video_usage_test.go @@ -0,0 +1,55 @@ +package repository + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestVideoTokenUsageAmountIgnoresTextCharges(t *testing.T) { + order := model.BillingOrder{ + Capability: "video", InputTokenPriceMicrocredits: 30_000_000, + OutputTokenPriceMicrocredits: 16_000_000, CachedTokenPriceMicrocredits: 20_000_000, + MultiplierBasisPoints: 10_000, + } + usage := &BillingUsage{InputTokens: 1000, OutputTokens: 108900, CachedTokens: 200} + amount, err := tokenUsageAmount(order, usage) + if err != nil || amount != 1_742_400 { + t.Fatalf("video settlement = %d, %v, want 1742400", amount, err) + } + if usage.InputTokens != 1000 || usage.CachedTokens != 200 { + t.Fatalf("settlement mutated observed usage: %#v", usage) + } +} + +func TestVideoTokenUsageAmountRejectsInvalidUsage(t *testing.T) { + order := model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 16_000_000, MultiplierBasisPoints: 10_000} + for _, test := range []struct { + name string + usage *BillingUsage + }{ + {name: "missing"}, + {name: "zero output", usage: &BillingUsage{}}, + {name: "negative output", usage: &BillingUsage{OutputTokens: -1}}, + {name: "negative input", usage: &BillingUsage{InputTokens: -1, OutputTokens: 108900}}, + {name: "negative cached", usage: &BillingUsage{CachedTokens: -1, OutputTokens: 108900}}, + {name: "overflow", usage: &BillingUsage{OutputTokens: 1<<63 - 1}}, + } { + t.Run(test.name, func(t *testing.T) { + if amount, err := tokenUsageAmount(order, test.usage); err == nil { + t.Fatalf("invalid usage settled to %d", amount) + } + }) + } +} + +func TestTextTokenUsageAmountPreservesInputOutputAndCachePrices(t *testing.T) { + amount, err := tokenUsageAmount(model.BillingOrder{ + Capability: "text", InputTokenPriceMicrocredits: 2_000_000, + OutputTokenPriceMicrocredits: 5_000_000, CachedTokenPriceMicrocredits: 1_000_000, + MultiplierBasisPoints: 10_000, + }, &BillingUsage{InputTokens: 100, OutputTokens: 20, CachedTokens: 40}) + if err != nil || amount != 260 { + t.Fatalf("text settlement = %d, %v, want 260", amount, err) + } +} diff --git a/backend/internal/service/aliases_types.go b/backend/internal/service/aliases_types.go index 7ee247630..75c5c8e05 100644 --- a/backend/internal/service/aliases_types.go +++ b/backend/internal/service/aliases_types.go @@ -68,6 +68,8 @@ type ( AnnouncementPage = app.AnnouncementPage AppError = app.AppError AppearanceSetting = app.AppearanceSetting + AppearanceSkinButtonFill = app.AppearanceSkinButtonFill + AppearanceSkinButtons = app.AppearanceSkinButtons AppearanceSkinComponentTokens = app.AppearanceSkinComponentTokens AppearanceSkinModeTokens = app.AppearanceSkinModeTokens AppearanceSkinTheme = app.AppearanceSkinTheme @@ -96,6 +98,7 @@ type ( ChannelModelCatalogOptions = app.ChannelModelCatalogOptions ChannelModelPriceTierRequest = app.ChannelModelPriceTierRequest ChannelModelRequest = app.ChannelModelRequest + ChannelModelQuoteRequest = app.ChannelModelQuoteRequest ChannelModelSortRequest = app.ChannelModelSortRequest ChannelModelsRequest = app.ChannelModelsRequest ChannelOrderItem = app.ChannelOrderItem diff --git a/docs/content/docs/backend/video-token-billing.mdx b/docs/content/docs/backend/video-token-billing.mdx new file mode 100644 index 000000000..ff0e92b86 --- /dev/null +++ b/docs/content/docs/backend/video-token-billing.mdx @@ -0,0 +1,65 @@ +--- +title: 视频 Token 计费 +description: 所有视频模型共用火山式公式定价、预留与用量结算 +--- + +# 视频 Token 计费 + +所有系统视频模型均支持 `billingMode: "token"`,不设协议白名单,包括 `newapi-channel-2`、Ark、Agent Plan 和自定义视频协议。当前统一采用火山式像素帧公式:有有效上游用量时优先采用上游数值;没有有效用量时按提交时保存的公式 Token 数结算。图片和音频仍不开放 Token 定价。 + +## 管理员配置 + +视频 Token 单价单位是「积分 / 百万视频 Token」,保存为每百万 Token 的微积分价格。输入价与缓存价必须为 0,输出价允许为 0(明确免费),最大为 1,000,000 积分。平台不自动导入人民币报价,也不自动调整已有模型价格。 + +同一模型可以按生成方式、分辨率、时长、参考图数量、是否生成音频配置价格档。`selector.videoGenerateAudio` 为字符串 `"true"` 或 `"false"`;不限音频时省略该条件。参考视频使用已有 `operation: "video_to_video"`,不另加参考视频布尔字段。显式关闭音频的 `false` 参与匹配,不能当作未填写。 + +前台逻辑模型沿用渠道价时使用实际命中的规格价格;统一定价时使用逻辑模型的费率。报价和任务预留均按所选线路的上游模型 ID 识别 Seedance 代际,内部展示别名不改变像素表。 + +## 提交前估算与预留 + +参考[火山引擎模型价格](https://www.volcengine.com/docs/82379/1544106)与[视频生成说明](https://www.volcengine.com/docs/82379/2298881),平台统一按 24 FPS 为所有视频模型估算: + +```text +公式 Token = ceil((参考视频总秒数 + 输出视频秒数) × 输出宽 × 输出高 × 24 ÷ 1024) +预留 Token = ceil(公式 Token × 1.10) +预留微积分 = ceil(预留 Token × 每百万 Token 微积分单价 × 平台倍率 ÷ 1,000,000) +``` + +10% 是平台预留策略,不属于火山引擎公式,也不保证覆盖最终费用。Seedance 2.x 参考视频有与清晰度、画幅及生成时长相关的最低消耗,供应商没有统一的闭式估算公式;实际费用可能超过预留。 + +像素表按模型代际区分,不将「720p」直接当成固定面积,也不因为模型名含 `fast` 就强制降为 720p。例如: + +| 模型与规格 | 预估输出像素 | +| --- | --- | +| Seedance 1.0,720p,16:9 | 1248 × 704 | +| Seedance 1.5 / 2.0,720p,16:9 | 1280 × 720 | +| Seedance 2.5,480p,16:9 | 854 × 480 | +| Seedance 2.0,4K,4:3 | 3326 × 2494 | + +完整表在 `backend/internal/app/video_token_billing.go`。自适应画幅使用该清晰度下最大的已知面积;上游型号无法识别代际时使用请求画幅下已知代际的最大面积,并标注尺寸为估算值。`size` 为明确的 `宽x高` 时直接使用像素;其他正数分辨率按短边和画幅推导,长边向上对齐偶数,2K 按 1440p、4K 按 2160p。缺少尺寸配置时平台计价约定为 720p、16:9,并标记尺寸估算;通用分辨率的自适应画幅按 16:9 推导。这些是平台计价规则,不保证上游输出相同像素。无效时长、非法尺寸和溢出仍拒绝,实际可生成规格仍由模型能力校验。 + +参考视频数量和时长限制由所选模型能力校验,通用计费公式不再强制套用 Ark 的 3 个、15 秒限制。任务包含时长元数据时按毫秒合计;时长缺失时公式采用已知时长与 15 秒中的较大值并标注估算。报价接口只接收素材数量,因此参考视频按 15 秒估算;任务创建时的公式快照可因实际元数据而不同。负时长、小数毫秒或整数溢出均拒绝。 + +例如,无参考视频的 Seedance 2.0、720p、16:9、5 秒:公式为 108,000 Token,平台预留 118,800 Token。若管理员配置 16 积分 / 百万 Token、平台倍率为 1,预留 1.9008 积分。16 只是计算示例,不是内置售价。 + +## 成功后的结算 + +Ark 协议读取[查询视频任务接口](https://www.volcengine.com/docs/82379/1521309)返回的 `usage.completion_tokens`,字段缺少时接受 `total_tokens`。其他视频协议按 `completion_tokens`、`output_tokens`、`total_tokens` 的顺序读取;优先字段存在但无效时不改用后续字段。视频不另收文本输入或缓存费用。0、负数、小数、字符串、空值、溢出和非成功状态的用量不视为有效上游用量,转为公式快照结算;不从输入量或不明用量字段推断视频消耗。 + +```text +实际微积分 = ceil(实际视频 Token × 订单快照中的单价 × 订单倍率 ÷ 1,000,000) +``` + +账单保存提交时的价格档、单价、倍率、预留量以及不含 10% 余量的 `videoFormulaTokens`。有效上游用量优先结算,记录 `usageSource: "provider"`、`usageAvailable: true`;否则新视频订单采用公式快照,记录 `usageSource: "video_formula"`、`usageAvailable: false`,`outputTokens` 为公式数,账本说明标明公式结算,不伪造上游调用日志。以上 5 秒示例缺少上游用量时结算 1.728 积分,退回预留差额 0.1728 积分。 + +较少的费用退回差额,较多的费用沿用现有补扣机制;Agent 硬预算上限继续生效。失败任务沿用退款或待核对规则,不因存在公式快照就自动收费。没有公式快照的历史收费订单仍要求有效上游用量,不从含预留余量的数量反推历史费用。免费档可零金额完成。数据库读取或实际金额计算出错时仍明确报错,不把错误当成缺少用量。 + +预估与结算共用 `internal/kernel/token_billing.go` 的整数金额算法,汇总 Token 费用、应用倍率后只对最终微积分向上取整,避免合法高单价因中间乘积溢出而被拒绝;最终金额超出 `int64` 范围时明确失败。 + +普通用户在模型选择器、画布生成入口与短剧生产页看到当前参数的「预计」金额,可查看公式 Token、预留余量以及估算边界。参数变更后重新报价;报价本身不创建账单、不冻结积分、不请求上游生成。 + +## 发布与验证 + +前后端需一起发布,并执行 schema v25 `video_token_formula_snapshot`:在 `billing_orders` 增加 `video_formula_tokens`(默认 0)与 `usage_source`(默认空字符串),不重算历史订单、不改既有模型售价。关闭自动迁移的部署先执行新版本 `migrate-schema up`、`migrate-schema verify`,然后启动后端。回退旧二进制需匹配数据库备份和 schema 版本,不能手工伪造迁移记录;旧程序也不支持新音频 selector 和非 Ark 视频 Token 配置。本次仅实现并测试迁移,没有操作真实账号数据库。 + +本地测试覆盖模型像素、公式取整、参考时长、音频分档、免费档、渠道与逻辑模型报价、任务预留一致性,以及真实用量的退差、补扣和非法值拒绝。真实账号页面及真实上游账单仍需按[待测试清单](/docs/progress/pending-test)验收。 diff --git a/web/src/components/model-logo.tsx b/web/src/components/model-logo.tsx index 0531c3084..d078c5821 100644 --- a/web/src/components/model-logo.tsx +++ b/web/src/components/model-logo.tsx @@ -8,12 +8,13 @@ import { cn } from "@/lib/utils"; type LobeIconComponent = ComponentType & { size?: number | string }>; -// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块, +// 优先使用品牌原色,缺少彩色版本时使用 Mono。不能使用 eager glob:目录有数百个模块, // eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。 const iconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" }); +const colorIconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Color.js", { import: "default" }); const iconLoaders = Object.fromEntries( - Object.entries(iconModules) - .map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader]) + [...Object.entries(iconModules), ...Object.entries(colorIconModules)] + .map(([path, loader]) => [path.match(/\/([^/]+)\/components\/(?:Mono|Color)\.js$/)?.[1], loader]) .filter((entry): entry is [string, () => Promise] => Boolean(entry[0] && entry[1])), ) as Record Promise>; const iconRegistry = new Map(); diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index b2a450278..891a93104 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -4,14 +4,15 @@ import { Popover } from "antd"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities"; -import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing"; -import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; +import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing"; +import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; +import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel } from "@/lib/model-picker-groups"; import { cn } from "@/lib/utils"; -import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; +import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { ModelLogo } from "@/components/model-logo"; -import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models"; +import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models"; type ModelPickerProps = { config: AiConfig; @@ -58,26 +59,11 @@ export function ModelPicker({ const menuRef = useRef(null); const triggerRef = useRef(null); const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]); - const optionGroups = useMemo(() => { - const channelGroups = config.channels - .map((channel) => ({ - key: channel.id, - label: channel.name || "未命名渠道", - scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : channel.scope === "system" ? "平台服务" : "我的模型", - models: groupModelsByDisplayName( - config, - options.filter((model) => resolveModelChannel(config, model).id === channel.id), - ), - })) - .filter((group) => group.models.length); - // options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃, - // 不再显示“其他模型 / 未指定渠道”这种不可用入口。 - return channelGroups; - }, [config, options]); + const optionGroups = useMemo(() => groupModelsForPicker(config, options), [config, options]); const storedCurrent = value?.trim() || ""; // 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。 const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined; - const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent; + const resolvedCurrent = isDirectSystemModel(config, storedCurrent) ? storedCurrent : resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent; // 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。 const current = options.includes(resolvedCurrent) ? resolvedCurrent : ""; const currentPrice = modelMenuPrice(config, current, capability, false, requirements); @@ -102,7 +88,7 @@ export function ModelPicker({ } const controller = new AbortController(); setRouteQuote(undefined); - quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal) + quoteModel(quoteRequest, controller.signal) .then((payload) => setRouteQuote(payload.quote)) .catch(() => { if (!controller.signal.aborted) setRouteQuote(undefined); @@ -142,7 +128,7 @@ export function ModelPicker({ }; const focusMenuOption = (last = false) => { window.requestAnimationFrame(() => { - const buttons = menuRef.current?.querySelectorAll('[role="option"]'); + const buttons = menuRef.current?.querySelectorAll('[data-model-picker-item]:not(:disabled)'); const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0); target?.focus(); }); @@ -160,8 +146,14 @@ export function ModelPicker({ triggerRef.current?.focus(); return; } + if (event.key === "ArrowLeft" && activeGroupKey !== null) { + event.preventDefault(); + setActiveGroupKey(null); + focusMenuOption(); + return; + } if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - const buttons = Array.from(event.currentTarget.querySelectorAll('[role="option"]')); + const buttons = Array.from(event.currentTarget.querySelectorAll('[data-model-picker-item]:not(:disabled)')); if (!buttons.length) return; event.preventDefault(); const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement); @@ -191,32 +183,32 @@ export function ModelPicker({ > {optionGroups.length ? ( activeGroupKey === null ? ( -
+
{optionGroups.map((group) => { const groupCurrent = group.models.find((item) => item.models.includes(current)); const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; - return ; })}
) :
-
+
{optionGroups.map((group) => { const groupCurrent = group.models.find((item) => item.models.includes(current)); const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || ""; return ; })}
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) =>
- + {group.label}{group.scope ? {group.scope} : null}
@@ -229,6 +221,7 @@ export function ModelPicker({
@@ -224,7 +225,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const filteredItems = items.filter((item) => { const query = keyword.trim().toLowerCase(); - if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName}`.toLowerCase().includes(query)) return false; + if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName} ${item.channelLabel || ""}`.toLowerCase().includes(query)) return false; if (capability !== "all" && item.capability !== capability) return false; if (status === "enabled" && !item.enabled) return false; if (status === "disabled" && item.enabled) return false; @@ -473,14 +474,14 @@ function billingSummary(item: ChannelModel) { return (
{tiers.slice(0, 3).map((tier) => ( -
{priceTierLabel(tier)}
+
{priceTierLabel(tier, item.capability)}
))} {tiers.length > 3 ?
另有 {tiers.length - 3} 个规格价格档
: null}
); } -function priceTierLabel(tier: ChannelModelPriceTier) { +function priceTierLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) { const selector = tier.selector || {}; const specParts = [ selector.operation && selector.operation !== "*" ? operationLabel(selector.operation) : "任意生成方式", @@ -489,9 +490,10 @@ function priceTierLabel(tier: ChannelModelPriceTier) { tier.resolution === "*" ? "" : tier.resolution.toUpperCase(), tier.videoSeconds ? `${tier.videoSeconds} 秒` : "", selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "", + selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "", ].filter(Boolean); const spec = specParts.length ? specParts.join(" / ") : "默认规格"; - if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`; + if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} 积分 / 百万${capability === "video" ? "视频 " : " "}Token`; return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`; } diff --git a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx index c2753877d..a9c4899fd 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx +++ b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx @@ -38,7 +38,7 @@ export function PriceTierFields({ const video = capabilityConfig?.video; const resolutionOptions = video?.resolutions || []; const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : []; - const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol)); + const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol); const isVideo = capability === "video"; const isImage = capability === "image"; return ( @@ -91,14 +91,14 @@ export function PriceTierFields({ options={[ { label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second", disabled: !isVideo }, - { label: "Token", value: "token", disabled: !tokenEnabled }, + { label: isVideo ? "视频 Token" : "Token", value: "token", disabled: !tokenEnabled }, ]} /> {billingMode === "token" ? ( isVideo ? ( - - + + ) : (
@@ -119,6 +119,11 @@ export function PriceTierFields({ )}
+ {isVideo && billingMode === "token" ? ( +

+ 所有视频模型均支持 Token 计费,统一按火山引擎模式计算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024。优先按成功任务返回的有效用量结算,未返回用量时按公式结算。平台额外预留 10%,不计入公式结算;最终可能补扣或退回差额。0 表示免费;有声、无声或参考视频可分别配置规格价格。 +

+ ) : null}
{matchMode !== "default" && (
@@ -135,6 +140,11 @@ export function PriceTierFields({ {durationOptions.length ? + + ) : null} {isVideo ? ( diff --git a/web/src/pages/admin/components/channel-model-price-tier-form.ts b/web/src/pages/admin/components/channel-model-price-tier-form.ts index 4559ab83b..9c8657899 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-form.ts +++ b/web/src/pages/admin/components/channel-model-price-tier-form.ts @@ -15,6 +15,7 @@ export type PriceTierFormValues = { size: string; resolution: string; videoSeconds: number; + videoGenerateAudio: string; imageCount: number; providerModelKey?: string; billingMode: ChannelModel["billingMode"]; @@ -34,6 +35,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri size: "*", resolution: "*", videoSeconds: 0, + videoGenerateAudio: "*", imageCount: 0, providerModelKey: "", billingMode: "fixed_request", @@ -56,6 +58,7 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue size: selector.size || "*", resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, + videoGenerateAudio: selector.videoGenerateAudio || "*", imageCount: Number(selector.imageCount || 0), providerModelKey: tier.providerModelKey || "", billingMode: tier.billingMode, @@ -91,6 +94,7 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution; if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds)); if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount)); + if (tier.videoGenerateAudio && tier.videoGenerateAudio !== "*") selector.videoGenerateAudio = tier.videoGenerateAudio; } if (capability === "image") { if (tier.quality && tier.quality !== "*") selector.quality = tier.quality; @@ -106,3 +110,20 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) { return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0; } + +export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) { + const videoTokens = capability === "video" && tier.billingMode === "token"; + return { + selector: skuSelectorFromForm(capability, tier), + resolution: priceTierResolutionFromForm(capability, tier), + videoSeconds: priceTierVideoSecondsFromForm(capability, tier), + providerModelKey: tier.providerModelKey?.trim() || upstreamModel, + billingMode: tier.billingMode, + unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000), + inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.inputTokenPrice || 0) * 1_000_000), + outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000), + cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.cachedTokenPrice || 0) * 1_000_000), + priceConfigured: tier.priceConfigured !== false, + enabled: tier.enabled !== false, + }; +} diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 3ac20568c..8c21438d0 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -132,6 +132,25 @@ export type LogicalModelQuote = { quantity: number; amountMicrocredits: number; estimated: boolean; + videoTokenEstimate?: { + formulaTokens: number; + reservedTokens: number; + outputWidth: number; + outputHeight: number; + framesPerSecond: number; + outputSeconds: number; + referenceSeconds: number; + referenceDurationEstimated: boolean; + dimensionsEstimated: boolean; + reservationMarginPercent: number; + }; +}; + +export type ModelQuoteRequest = { + logicalModelID?: string; + channelId?: string; + modelKey?: string; + intent: ModelRequestIntent; }; export type ModelCatalogSource = "frontend" | "system"; @@ -148,6 +167,8 @@ export type PublicChannelModel = { id: string; modelKey: string; displayName: string; + channelLabel?: string; + description?: string; sortOrder?: number; icon: string; capability: string; @@ -187,6 +208,12 @@ export function quoteLogicalModel(id: string, intent: ModelRequestIntent, signal return http.post<{ quote: LogicalModelQuote }>(`/models/${encodeURIComponent(id)}/quote`, intent, { signal }); } +export function quoteModel(request: ModelQuoteRequest, signal?: AbortSignal) { + if (request.logicalModelID) return quoteLogicalModel(request.logicalModelID, request.intent, signal); + if (!request.channelId || !request.modelKey) return Promise.reject(new Error("请选择需要报价的系统模型")); + return http.post<{ quote: LogicalModelQuote }>("/model-catalog/quote", { channelId: request.channelId, modelKey: request.modelKey, intent: request.intent }, { signal }); +} + export function listAdminLogicalModels() { return http.get<{ models?: AdminLogicalModel[] }>("/admin/logical-models").then((result) => ({ models: Array.isArray(result?.models) ? result.models.filter(Boolean).map(normalizeAdminLogicalModel) : [], diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 7d51f2aef..150a1953f 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -52,6 +52,8 @@ export type ChannelModel = { modelKey: string; providerModelKey: string; displayName: string; + channelLabel?: string; + description?: string; sortOrder?: number; icon: string; capability: "text" | "image" | "video" | "audio" | ""; @@ -96,6 +98,8 @@ export type ChannelModelMutation = { modelKey: string; providerModelKey?: string; displayName?: string; + channelLabel?: string; + description?: string; icon?: string; capability: ChannelModel["capability"]; protocol?: ChannelModel["protocol"]; @@ -214,6 +218,8 @@ export type BillingOrder = { outputTokens: number; cachedTokens: number; usageAvailable: boolean; + videoFormulaTokens?: number; + usageSource?: "provider" | "video_formula"; status: "reserved" | "running" | "settled" | "refunded" | "uncertain"; providerRequestId?: string; error?: string; diff --git a/web/test/channel-model-editor-form.test.ts b/web/test/channel-model-editor-form.test.ts index 11c18778e..19eb1889a 100644 --- a/web/test/channel-model-editor-form.test.ts +++ b/web/test/channel-model-editor-form.test.ts @@ -28,11 +28,11 @@ describe("channel model editor drafts", () => { }); test("capability changes clear old selectors but preserve money and billing units", () => { const draft = initialChannelModelValues(null, protocols); - const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, imageCount: 2 }; + const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, videoGenerateAudio: "false", imageCount: 2 }; const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "video", providerModelKey: "gpt-image-2", priceTiers: [tier] }, protocols); expect(next.protocol).toBe("image"); expect(next.capabilityConfig).toEqual(defaultModelCapabilityConfig("image", "gpt-image-2")); - expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, imageCount: 0 }); + expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, videoGenerateAudio: "*", imageCount: 0 }); expect(tier.operation).toBe("text_to_video"); expect(() => validateChannelModelPrices(next)).toThrow("按秒"); }); @@ -63,16 +63,32 @@ describe("pricing write validation", () => { expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值"); } }); - test("a new protocol cannot silently convert Token pricing to per request", () => { - expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token"); + test("saves video Token prices with newapi-channel-2 and other video protocols without changing billing units", () => { + for (const protocol of ["newapi-channel-2", "volcengine-jimeng-video", "video"]) { + const priceTiers = [{ ...defaultPriceTier(), billingMode: "token" as const, outputTokenPrice: 8 }]; + expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol, priceTiers })).not.toThrow(); + expect(priceTiers[0].billingMode).toBe("token"); + expect(priceTiers[0].outputTokenPrice).toBe(8); + } }); - test("validates all text Token prices and the video Token minimum", () => { + test("rejects Token pricing after switching to image or audio", () => { + for (const capability of ["image", "audio"] as const) { + expect(() => validateChannelModelPrices({ ...draft, capability, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token"); + } + }); + test("validates all text Token prices and accepts free video Token pricing", () => { expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", inputTokenPrice: NaN }] })).toThrow(); const video = { ...draft, capability: "video" as const, protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token" as const }] }; - expect(() => validateChannelModelPrices(video)).toThrow("至少为"); + expect(() => validateChannelModelPrices(video)).not.toThrow(); video.priceTiers[0].outputTokenPrice = 0.000001; expect(() => validateChannelModelPrices(video)).not.toThrow(); }); + test("accepts audio-only video price conditions and rejects invalid audio selectors", () => { + for (const videoGenerateAudio of ["true", "false"]) { + expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", videoGenerateAudio }] })).not.toThrow(); + } + expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声"); + }); }); test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => { diff --git a/web/test/channel-model-price-tier-form.test.ts b/web/test/channel-model-price-tier-form.test.ts index 367493a92..33168a3e9 100644 --- a/web/test/channel-model-price-tier-form.test.ts +++ b/web/test/channel-model-price-tier-form.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { defaultPriceTier, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form"; +import { defaultPriceTier, priceTierPayloadFromForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form"; import type { ChannelModelPriceTier } from "../src/services/api/wallet"; describe("channel model price tier defaults", () => { @@ -20,7 +20,7 @@ describe("channel model price tier defaults", () => { }); test("drops stale video selectors after switching back to the default price", () => { - const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, imageCount: 2 }; + const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, videoGenerateAudio: "false", imageCount: 2 }; const defaultTier = { ...tier, matchMode: "default" as const }; expect(skuSelectorFromForm("video", defaultTier)).toEqual({}); @@ -51,5 +51,22 @@ describe("channel model price tier defaults", () => { expect(priceTierToForm(base).matchMode).toBe("default"); expect(priceTierToForm({ ...base, selector: { quality: "2k" } }).matchMode).toBe("advanced"); + expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "advanced", videoGenerateAudio: "false" }); + }); + + test("preserves explicit silent and audible selectors but omits the wildcard", () => { + for (const videoGenerateAudio of ["true", "false"]) { + const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio }; + expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio }); + expect(skuSelectorFromForm("image", tier)).toEqual({}); + } + expect(skuSelectorFromForm("video", defaultPriceTier("advanced"))).toEqual({}); + }); + + test("video Token writes discard hidden text prices without changing the configured video rate", () => { + const tier = { ...defaultPriceTier("advanced"), billingMode: "token" as const, videoGenerateAudio: "false", inputTokenPrice: 10, outputTokenPrice: 0.25, cachedTokenPrice: 5 }; + expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: { videoGenerateAudio: "false" }, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 }); + expect(priceTierPayloadFromForm("text", tier, "text-model")).toMatchObject({ inputTokenPriceMicrocredits: 10_000_000, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 5_000_000 }); + expect(priceTierPayloadFromForm("video", { ...tier, outputTokenPrice: 0 }, "seedance").outputTokenPriceMicrocredits).toBe(0); }); }); diff --git a/web/test/model-picker-groups.test.ts b/web/test/model-picker-groups.test.ts new file mode 100644 index 000000000..b3243a64b --- /dev/null +++ b/web/test/model-picker-groups.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups"; +import { modelCompatibilityError, resolveCompatibleModel } from "../src/lib/model-selection"; +import { modelQuoteRequest, priceTiersForCurrentSelection, priceTierSummaryLabel } from "../src/lib/model-pricing"; +import { systemChannelModelChannels } from "../src/lib/user-session"; +import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models"; +import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store"; +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; + +function model(label = "", price = 300000): PublicChannelModel { + return { + id: label || "test-model", modelKey: "seedance-2.0", displayName: "Seedance 2.0", channelLabel: label, + description: label ? `${label}的使用说明` : "", + icon: "ByteDance", capability: "video", protocol: "seedance", available: true, + pricingMode: "provider", priceLabel: "", + capabilityConfig: defaultModelCapabilityConfig("seedance", "seedance-2.0"), + priceTiers: [{ id: "tier", selector: {}, resolution: "*", videoSeconds: 0, billingMode: "per_second", unitPriceMicrocredits: price, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0 }], + }; +} + +function fixture() { + const channels: PublicChannelCatalog[] = [ + { id: "a", name: "正常渠道", displayName: "正常渠道", models: [model()] }, + { id: "b", name: "渠道 B", displayName: "渠道 B", models: [model("优惠渠道-993")] }, + { id: "c", name: "渠道 C", displayName: "渠道 C", models: [model("特惠渠道-730", 200000)] }, + ]; + return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config; +} + +test("one product exposes every channel and preserves prices through catalog normalization", () => { + const config = fixture(); + const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video")); + expect(groups).toHaveLength(1); + expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]); + expect(groups[0]).toMatchObject({ label: "Seedance 2.0", icon: "ByteDance", kind: "product" }); + expect(groups[0].models.map((item) => [item.label, item.models])).toEqual([ + ["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]], + ]); + expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]); +}); + +test("selection and quote keep the chosen channel even when another channel is cheaper", () => { + const config = fixture(); + const value = "b::seedance-2.0"; + expect(resolveCompatibleModel(config, value, { capability: "video" })).toBe(value); + expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" }); + expect(modelQuoteRequest(config, value, "video")).toMatchObject({ channelId: "b", modelKey: "seedance-2.0" }); + config.channels[1].modelCosts![0].channelLabel = "新展示名"; + expect(modelChannelLabel(config, value)).toBe("新展示名"); + expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" }); +}); + +test("product keys and capabilities stay distinct despite equal display names", () => { + const config = fixture(); + config.channels[1].models = ["seedance-2.0-fast"]; + config.channels[1].modelCosts![0].model = "seedance-2.0-fast"; + config.channels[2].modelCosts![0].capability = "image"; + const groups = groupModelsForPicker(config, ["a::seedance-2.0", "b::seedance-2.0-fast", "c::seedance-2.0"]); + expect(groups).toHaveLength(3); +}); + +test("group metadata is stable across channel reorder and current selection", () => { + const config = fixture(); + config.channels[1].modelCosts![0].displayName = "不同展示名"; + config.channels[1].modelCosts![0].icon = "Jimeng"; + const options = selectableModelsByCapability(config, "video"); + const first = groupModelsForPicker(config, options)[0]; + const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options)[0]; + expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]); + expect(reordered.models[0].models).toEqual(["c::seedance-2.0"]); +}); + +test("each channel keeps its own capability restrictions", () => { + const config = fixture(); + config.channels[0].modelCosts![0].capabilityConfig!.video!.references.maxImages = 0; + config.channels[1].modelCosts![0].capabilityConfig!.video!.references.maxImages = 2; + config.channels[1].modelCosts![0].capabilityConfig!.video!.operations = ["image_to_video"]; + const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } }; + expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe(""); + expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe(""); + expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3); +}); + +test("managed logical models and personal channels never become system product routes", () => { + const config = fixture(); + config.channels[1].id = "managed"; + config.channels[1].modelCosts![0].logicalModelId = "logical-test"; + config.channels[2].scope = "user"; + const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]); + expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]); + expect(groups[0].models).toHaveLength(1); + expect(groupModelsForPicker(config, [])).toEqual([]); +}); diff --git a/web/test/model-pricing.test.ts b/web/test/model-pricing.test.ts index 2bb5e7933..8982de438 100644 --- a/web/test/model-pricing.test.ts +++ b/web/test/model-pricing.test.ts @@ -8,7 +8,7 @@ import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generati import { modelRequestOptions } from "../src/lib/model-selection"; import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas"; -function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) { +function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; outputTokenPriceMicrocredits?: number }> }) { const capability = input.capability || "video"; const model = capability === "video" ? "agnes-video-2.5" : "image-model"; const channel = createModelChannel({ @@ -32,7 +32,7 @@ function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: resolution: tier.selector.vquality || "*", videoSeconds: Number(tier.selector.videoSeconds || 0), inputTokenPriceMicrocredits: 0, - outputTokenPriceMicrocredits: 0, + outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits ?? 0, cachedTokenPriceMicrocredits: 0, })), }, @@ -235,4 +235,38 @@ describe("model request pricing", () => { }, }); }); + + test("matches video audio, resolution and duration from actual request overrides", () => { + const config = systemConfig({ tiers: [ + { selector: {}, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1_000_000 }, + { selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 2_000_000 }, + { selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000 }, + ] }); + const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!; + for (const videoGenerateAudio of [false, "false", true, "true"]) { + const requirements = { ...textVideoRequirements, videoSeconds: "10", options: { vquality: "1080P", videoSeconds: 5, videoGenerateAudio } }; + const matched = priceTiersForCurrentSelection(tiers, "video", config, requirements); + expect(matched).toHaveLength(1); + expect(matched[0]?.outputTokenPriceMicrocredits).toBe(videoGenerateAudio === true || videoGenerateAudio === "true" ? 3_000_000 : 2_000_000); + expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "agnes-video-2.5", capability: "video", config, requirements })).toBeNull(); + } + }); + + test("shows the configured video Token rate including free and fractional rates", () => { + const config = systemConfig({ tiers: [ + { selector: { videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0 }, + { selector: { videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1 }, + ] }); + const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!; + expect(priceTierSummaryLabel(tiers, "video")).toBe("0-0.000001 积分/百万视频 Token"); + expect(priceTierSummaryLabel(tiers.slice(0, 1), "video")).toBe("0 积分/百万视频 Token"); + }); + + test("builds a direct system-video quote without inventing a logical model ID", () => { + const config = systemConfig({ tiers: [{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0 }] }); + const requirements = { ...textVideoRequirements, options: { videoGenerateAudio: false, vquality: "1080p" } }; + expect(modelQuoteRequest(config, config.model, "video", requirements)).toMatchObject({ channelId: "system-channel", modelKey: "agnes-video-2.5", intent: { options: { videoGenerateAudio: false, vquality: "1080p" } } }); + expect(modelQuoteRequest(config, config.model, "video", requirements)?.logicalModelID).toBeUndefined(); + expect(modelQuoteRequest(config, config.model, "image", requirements)).toBeUndefined(); + }); }); diff --git a/web/test/model-protocols.test.ts b/web/test/model-protocols.test.ts index 24a2ddad2..ae38d481a 100644 --- a/web/test/model-protocols.test.ts +++ b/web/test/model-protocols.test.ts @@ -3,13 +3,21 @@ import { describe, expect, test } from "bun:test"; import { modelProtocolSupportsTokenBilling } from "../src/lib/model-protocols"; describe("model protocol Token billing", () => { - test("supports text models and Volcengine Ark video only", () => { + test("supports text and every video protocol", () => { expect(modelProtocolSupportsTokenBilling("text", "chat-completion")).toBe(true); expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-video")).toBe(true); expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-agent-plan-video")).toBe(true); - expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(false); - expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(false); + expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(true); + expect(modelProtocolSupportsTokenBilling("video", "newapi-channel-2")).toBe(true); + expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(true); + expect(modelProtocolSupportsTokenBilling("video", "custom-video-protocol")).toBe(true); + expect(modelProtocolSupportsTokenBilling("video")).toBe(true); + }); + + test("does not enable Token billing for image, audio or unselected capabilities", () => { expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-image")).toBe(false); expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-agent-plan-image")).toBe(false); + expect(modelProtocolSupportsTokenBilling("audio", "speech")).toBe(false); + expect(modelProtocolSupportsTokenBilling(undefined, "volcengine-ark-video")).toBe(false); }); }); diff --git a/web/test/model-quote.test.ts b/web/test/model-quote.test.ts new file mode 100644 index 000000000..37c9ae810 --- /dev/null +++ b/web/test/model-quote.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { modelQuoteDescription } from "../src/lib/model-pricing"; +import { quoteModel, type LogicalModelQuote, type ModelRequestIntent } from "../src/services/api/logical-models"; +import { apiClient } from "../src/services/api/request"; + +const intent: ModelRequestIntent = { capability: "video", operation: "text_to_video", inputs: { image: 0, video: 0, audio: 0 }, options: { videoSeconds: 5, videoGenerateAudio: false } }; +const quote: LogicalModelQuote = { logicalModelId: "", billingMode: "token", quantity: 1, amountMicrocredits: 500_000, estimated: true }; + +describe("model quote API targets", () => { + test("direct system videos send the actual options and cancellation signal to the catalog quote", async () => { + const original = apiClient.request; + const calls: unknown[] = []; + apiClient.request = (async (config: unknown) => { + calls.push(config); + return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} }; + }) as typeof apiClient.request; + try { + const controller = new AbortController(); + expect(await quoteModel({ channelId: "channel-1", modelKey: "seedance", intent }, controller.signal)).toEqual({ quote }); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "post", url: "/model-catalog/quote", data: { channelId: "channel-1", modelKey: "seedance", intent }, signal: controller.signal }); + } finally { + apiClient.request = original; + } + }); + + test("logical models retain their dedicated quote route", async () => { + const original = apiClient.request; + const calls: unknown[] = []; + apiClient.request = (async (config: unknown) => { + calls.push(config); + return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} }; + }) as typeof apiClient.request; + try { + await quoteModel({ logicalModelID: "logical/video", intent }); + expect(calls[0]).toMatchObject({ method: "post", url: "/models/logical%2Fvideo/quote", data: intent }); + } finally { + apiClient.request = original; + } + }); + + test("missing quote targets fail before issuing a request", async () => { + await expect(quoteModel({ intent })).rejects.toThrow("请选择"); + }); +}); + +test("video estimates disclose platform reserves, unknown references and final settlement", () => { + const description = modelQuoteDescription({ + ...quote, + videoTokenEstimate: { formulaTokens: 100_000, reservedTokens: 110_000, outputWidth: 1280, outputHeight: 720, framesPerSecond: 24, outputSeconds: 5, referenceSeconds: 15, referenceDurationEstimated: true, dimensionsEstimated: true, reservationMarginPercent: 10 }, + }); + expect(description).toContain("预计消耗 0.5 积分"); + expect(description).toContain("公式预估 100,000 视频 Token"); + expect(description).toContain("平台额外预留 10%"); + expect(description).toContain("15 秒"); + expect(description).toContain("并非费用上限"); + expect(description).toContain("未返回用量时按火山引擎公式结算"); + expect(description).toContain("额外预留不计入公式结算"); + expect(description).toContain("补扣或退回差额"); +}); + +test("text Token quotes retain upstream usage settlement without a video formula", () => { + const description = modelQuoteDescription(quote); + expect(description).toContain("实际用量结算"); + expect(description).not.toContain("火山引擎公式"); +});