feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示
This commit is contained in:
@@ -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 {
|
||||
|
||||
86
backend/internal/app/all_video_token_integration_test.go
Normal file
86
backend/internal/app/all_video_token_integration_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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-- {
|
||||
|
||||
86
backend/internal/app/channel_model_label_test.go
Normal file
86
backend/internal/app/channel_model_label_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
73
backend/internal/app/finance_token_arithmetic_test.go
Normal file
73
backend/internal/app/finance_token_arithmetic_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 计费仅支持文本和视频前台模型")
|
||||
}
|
||||
}
|
||||
// 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
129
backend/internal/app/video_logical_billing_test.go
Normal file
129
backend/internal/app/video_logical_billing_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
backend/internal/app/video_token_billing.go
Normal file
241
backend/internal/app/video_token_billing.go
Normal 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
|
||||
}
|
||||
67
backend/internal/app/video_token_billing_test.go
Normal file
67
backend/internal/app/video_token_billing_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
221
backend/internal/app/video_token_quote_test.go
Normal file
221
backend/internal/app/video_token_quote_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
backend/internal/app/video_token_tier_test.go
Normal file
123
backend/internal/app/video_token_tier_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
90
backend/internal/app/video_usage_test.go
Normal file
90
backend/internal/app/video_usage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
backend/internal/database/channel_model_description_test.go
Normal file
40
backend/internal/database/channel_model_description_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
40
backend/internal/database/channel_model_label_test.go
Normal file
40
backend/internal/database/channel_model_label_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
44
backend/internal/database/migrations_video_token_test.go
Normal file
44
backend/internal/database/migrations_video_token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
41
backend/internal/kernel/token_billing.go
Normal file
41
backend/internal/kernel/token_billing.go
Normal 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
|
||||
}
|
||||
90
backend/internal/kernel/token_billing_test.go
Normal file
90
backend/internal/kernel/token_billing_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
94
backend/internal/repository/finance_token_arithmetic_test.go
Normal file
94
backend/internal/repository/finance_token_arithmetic_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
183
backend/internal/repository/finance_video_formula_test.go
Normal file
183
backend/internal/repository/finance_video_formula_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
backend/internal/repository/finance_video_usage_test.go
Normal file
55
backend/internal/repository/finance_video_usage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user