feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示

This commit is contained in:
ddCat
2026-09-18 23:26:54 +08:00
parent 9eb2fcd453
commit 531019b368
57 changed files with 2904 additions and 341 deletions

View File

@@ -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 {

View File

@@ -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)
}
})
}
}

View File

@@ -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-- {

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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 计费仅支持文本和视频前台模型")
}
}
// 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。

View File

@@ -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")
}
}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
})
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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"`

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)
}
})
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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