diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 1732d3c4a..295b201a5 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -387,7 +387,9 @@ func (r *Repository) BillingUsage(orderID string) (*BillingUsage, error) { func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) { var log model.ApiCallLog - err := db.Where("billing_order_id = ? AND billable = ? AND status = ? AND usage_available = ?", orderID, true, model.ApiCallStatusSucceeded, true). + // 异步视频的真实 usage 由非计费的轮询请求返回;订单本身已经限定了归属, + // 结算应读取同订单最新的成功 usage,而不是只看创建请求。 + err := db.Where("billing_order_id = ? AND status = ? AND usage_available = ?", orderID, model.ApiCallStatusSucceeded, true). Order("created_at desc").First(&log).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrBillingUsageUnavailable @@ -615,6 +617,9 @@ func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, err if usage == nil { return 0, ErrBillingUsageUnavailable } + if order.Capability == "video" && usage.OutputTokens <= 0 { + return 0, ErrBillingUsageUnavailable + } input := usage.InputTokens - usage.CachedTokens if input < 0 { input = 0 diff --git a/backend/internal/repository/finance_token_test.go b/backend/internal/repository/finance_token_test.go new file mode 100644 index 000000000..71812769c --- /dev/null +++ b/backend/internal/repository/finance_token_test.go @@ -0,0 +1,101 @@ +package repository + +import ( + "errors" + "testing" + + "infinite-canvas/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestTokenUsageAmountSettlesArkVideoCompletionTokens(t *testing.T) { + amount, err := tokenUsageAmount(model.BillingOrder{ + Capability: "video", + OutputTokenPriceMicrocredits: 16_000_000, + MultiplierBasisPoints: 10_000, + }, &BillingUsage{OutputTokens: 108900}) + if err != nil { + t.Fatalf("tokenUsageAmount() error = %v", err) + } + if amount != 1_742_400 { + t.Fatalf("tokenUsageAmount() = %d", amount) + } +} + +func TestBillingUsageReadsAsyncVideoPollUsage(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:finance-token-poll?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ApiCallLog{ + ID: "poll-log-1", BillingOrderID: "order-1", RequestKind: "poll", Billable: false, + Status: model.ApiCallStatusSucceeded, UsageAvailable: true, OutputTokens: 108900, + }).Error; err != nil { + t.Fatal(err) + } + usage, err := billingUsage(db, "order-1") + if err != nil { + t.Fatalf("billingUsage() error = %v", err) + } + if usage.OutputTokens != 108900 { + t.Fatalf("billingUsage() = %#v", usage) + } +} + +func TestTokenUsageAmountRejectsVideoWithoutOutputUsage(t *testing.T) { + _, err := tokenUsageAmount(model.BillingOrder{Capability: "video", OutputTokenPriceMicrocredits: 16_000_000, MultiplierBasisPoints: 10_000}, &BillingUsage{}) + if !errors.Is(err, ErrBillingUsageUnavailable) { + t.Fatalf("tokenUsageAmount() error = %v", err) + } +} + +func TestSettleArkVideoTokenOrderFromPollUsage(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:finance-token-settle?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.CreditAccount{}, &model.BillingOrder{}, &model.ApiCallLog{}, &model.CreditLedgerEntry{}); err != nil { + t.Fatal(err) + } + const reserved = int64(1_916_640) + if err := db.Create(&model.CreditAccount{UserID: "user-1", ReservedMicrocredits: reserved}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.BillingOrder{ + ID: "order-1", UserID: "user-1", IdempotencyKey: "task:task-1", Capability: "video", BillingMode: "token", + AmountMicrocredits: reserved, ReservedAmountMicrocredits: reserved, OutputTokenPriceMicrocredits: 16_000_000, + MultiplierBasisPoints: 10_000, Status: model.BillingStatusRunning, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.ApiCallLog{ + ID: "poll-log-1", BillingOrderID: "order-1", RequestKind: "poll", Billable: false, + Status: model.ApiCallStatusSucceeded, UsageAvailable: true, OutputTokens: 108900, + }).Error; err != nil { + t.Fatal(err) + } + + repo := &Repository{db: db} + if err := repo.SettleBillingOrder("order-1", "ark-task-1"); err != nil { + t.Fatalf("SettleBillingOrder() error = %v", err) + } + var order model.BillingOrder + if err := db.First(&order, "id = ?", "order-1").Error; err != nil { + t.Fatal(err) + } + if order.Status != model.BillingStatusSettled || order.ActualAmountMicrocredits != 1_742_400 || order.RefundedAmountMicrocredits != 174_240 { + t.Fatalf("settled order = %#v", order) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "user-1").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 174_240 || account.ReservedMicrocredits != 0 { + t.Fatalf("settled account = %#v", account) + } +} diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 85f05b14e..e3d4bb25d 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -940,6 +940,14 @@ func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { log.UsageAvailable = true log.InputTokens = firstInt64(usage, "input_tokens", "prompt_tokens") log.OutputTokens = firstInt64(usage, "output_tokens", "completion_tokens") + // 火山方舟视频的输入 Token 恒为 0;查询任务以 completion_tokens 为实际用量, + // 兼容只返回 total_tokens 的同协议中转实现。 + if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens == 0 { + log.OutputTokens = firstInt64(usage, "total_tokens") + } + if log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") && log.OutputTokens <= 0 { + log.UsageAvailable = false + } if details, ok := usage["input_tokens_details"].(map[string]any); ok { log.CachedTokens = firstInt64(details, "cached_tokens") } diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index c7d4030fe..9a7a212af 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -152,8 +152,8 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if billingMode == "per_second" && capability != "video" { return nil, BadAuthRequest("只有视频模型可以按秒计费") } - if billingMode == "token" && capability != "text" { - return nil, BadAuthRequest("只有文本模型可以按 Token 计费") + if billingMode == "token" && !supportsTokenBilling(capability, protocol) { + return nil, BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议") } if req.UnitPriceMicrocredits < 0 || req.InputTokenPriceMicrocredits < 0 || req.OutputTokenPriceMicrocredits < 0 || req.CachedTokenPriceMicrocredits < 0 { return nil, BadAuthRequest("模型积分价格不能小于 0") @@ -161,6 +161,9 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if billingMode == "token" && req.InputTokenPriceMicrocredits == 0 && req.OutputTokenPriceMicrocredits == 0 && req.CachedTokenPriceMicrocredits == 0 { return nil, BadAuthRequest("Token 计费至少需要配置一项价格") } + if billingMode == "token" && capability == "video" && req.OutputTokenPriceMicrocredits == 0 { + return nil, BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格") + } const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale if req.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { return nil, BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") @@ -222,6 +225,10 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id return item, nil } +func supportsTokenBilling(capability string, protocol model.ChannelInterfaceType) bool { + return capability == "text" || (capability == "video" && protocol == model.ChannelInterfaceVolcengineArkVideo) +} + func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 53ecf8d6b..59ff7b284 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -449,7 +449,7 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st capability = capabilityFromTaskType(task.Type) } scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type) - return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskTokens(input)) + return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability)) } func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) { @@ -507,12 +507,15 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s } quantity = requestedQuantity case "token": - if item.Capability != "text" || capability != "text" { - return nil, BadAuthRequest("Token 计费仅适用于文本生成") + if !supportsTokenBilling(item.Capability, item.Protocol) || item.Capability != capability { + return nil, BadAuthRequest("Token 计费仅支持文本生成和火山方舟视频生成") } - if tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0 { + if capability == "text" && (tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0) { return nil, BadAuthRequest("无法估算文本 Token 用量") } + if capability == "video" && tokenEstimate.OutputTokens <= 0 { + return nil, BadAuthRequest("无法估算火山方舟视频 Token 用量") + } quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens default: return nil, BadAuthRequest("当前模型计费方式暂不支持") @@ -549,6 +552,101 @@ func estimateTaskTokens(input map[string]any) tokenBillingEstimate { return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)} } +func estimateTaskBillingTokens(input map[string]any, capability string) tokenBillingEstimate { + if capability == "video" { + return estimateArkVideoTokens(input) + } + 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) @@ -581,7 +679,7 @@ func maxOutputTokens(payload map[string]any) int64 { // Token 单价按每百万 Token 配置;预授权使用输入价估算缓存 Token,真实结算再按 usage 拆分。 func tokenEstimateAmount(item *model.ChannelModel, estimate tokenBillingEstimate, multiplierBPS int64) (int64, error) { - if item == nil || estimate.InputTokens <= 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 { + if item == nil || estimate.InputTokens < 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 { return 0, errors.New("Token 计费参数无效") } inputAmount, ok := safeTokenProduct(estimate.InputTokens, item.InputTokenPriceMicrocredits) diff --git a/backend/internal/service/finance_token_test.go b/backend/internal/service/finance_token_test.go new file mode 100644 index 000000000..a83bbab26 --- /dev/null +++ b/backend/internal/service/finance_token_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestSupportsTokenBillingForVolcengineArkVideo(t *testing.T) { + if !supportsTokenBilling("text", model.ChannelInterfaceChatCompletion) { + t.Fatal("text protocol should support Token billing") + } + 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") + } +} + +func TestEstimateArkVideoTokensUsesPixelFrameEstimate(t *testing.T) { + estimate := estimateArkVideoTokens(map[string]any{ + "config": map[string]any{ + "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 { + t.Fatalf("estimateArkVideoTokens() = %#v", estimate) + } +} + +func TestEstimateArkVideoTokensIncludesUnknownReferenceVideo(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"}}, + }) + if estimate.OutputTokens != 477180 { + t.Fatalf("estimateArkVideoTokens() = %#v", estimate) + } +} + +func TestEstimateArkVideoTokensCapsReferenceDuration(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) + } +} + +func TestTokenEstimateAmountAllowsVideoOutputOnly(t *testing.T) { + amount, err := tokenEstimateAmount(&model.ChannelModel{OutputTokenPriceMicrocredits: 16_000_000}, tokenBillingEstimate{OutputTokens: 119790}, 10_000) + if err != nil { + t.Fatalf("tokenEstimateAmount() error = %v", err) + } + if amount != 1_916_640 { + t.Fatalf("tokenEstimateAmount() = %d", amount) + } +} + +func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/cgt-test"} + (&Service{}).EnrichAPICallLog(log, []byte(`{"id":"cgt-test","status":"succeeded","usage":{"completion_tokens":108900,"total_tokens":108900}}`)) + if !log.UsageAvailable || log.InputTokens != 0 || log.OutputTokens != 108900 { + t.Fatalf("EnrichAPICallLog() = %#v", log) + } + + fallback := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/cgt-test"} + (&Service{}).EnrichAPICallLog(fallback, []byte(`{"usage":{"total_tokens":35800}}`)) + if !fallback.UsageAvailable || fallback.OutputTokens != 35800 { + t.Fatalf("EnrichAPICallLog() total_tokens fallback = %#v", fallback) + } + + missing := &model.ApiCallLog{Capability: "video", Path: "/api/v3/contents/generations/tasks/cgt-test"} + (&Service{}).EnrichAPICallLog(missing, []byte(`{"usage":{}}`)) + if missing.UsageAvailable { + t.Fatalf("EnrichAPICallLog() accepted empty Ark usage: %#v", missing) + } +} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index fc04f1de2..36476e734 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -276,11 +276,16 @@ function formatDurationSummary(profile: NonNullable item.model === modelOptionName(model)); if (!cost) return channel.scope === "system" ? null : undefined; + if (cost.billingMode === "token") { + return { value: (cost.outputTokenPriceMicrocredits || 0) / 1_000_000, unit: "百万 Token" }; + } return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" }; } @@ -292,7 +297,7 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM return showConfiguredModelName ? `${configuredModelDisplayName(config, model)}(${resolveModelChannel(config, model).name})` : modelOptionLabel(config, model); } -function ModelPrice({ price, compact = false }: { price: { value: number; unit: "次" | "秒" } | null | undefined; compact?: boolean }) { +function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) { if (price === undefined) return null; if (price === null) return compact ? null : 未配置; return ( diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index ab938dffb..867082a97 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -88,6 +88,10 @@ export function modelProtocolCapability(value?: string) { return modelProtocolDefinition(value)?.capability; } +export function modelProtocolSupportsTokenBilling(capability?: string, protocol?: string) { + return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video"); +} + export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined { const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase())); if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion"; diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 4652beea3..c0a2c2ab2 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -8,7 +8,7 @@ import { ModelIcon } from "@/components/model-picker"; import { ModelCapabilityEditor } from "@/components/model-capability-editor"; import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker"; import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; -import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols"; +import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols"; import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; import { AdminPageFrame } from "./admin-shell"; @@ -46,6 +46,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const [form] = Form.useForm(); const billingMode = Form.useWatch("billingMode", form) || "fixed_request"; const modelCapability = Form.useWatch("capability", form); + const modelProtocol = Form.useWatch("protocol", form); const modelKey = Form.useWatch("modelKey", form) || ""; const reload = async () => { @@ -164,11 +165,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: if (changed.protocol && (modelCapability === "image" || modelCapability === "video")) { form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol, form.getFieldValue("modelKey"))); } - if (!changed.capability) return; const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined; - if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) { + const currentCapability = form.getFieldValue("capability") as EditableCapability | undefined; + const currentProtocol = form.getFieldValue("protocol") as ModelProtocol | undefined; + if ((currentBillingMode === "per_second" && currentCapability !== "video") || (currentBillingMode === "token" && !modelProtocolSupportsTokenBilling(currentCapability, currentProtocol))) { form.setFieldValue("billingMode", "fixed_request"); } + if (!changed.capability) return; const current = form.getFieldValue("protocol") as ModelProtocol | undefined; if (modelProtocolCapability(current) !== changed.capability) { const nextProtocol = MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value; @@ -258,20 +261,29 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: {modelCapability === "image" || modelCapability === "video" ? : null} - + {billingMode === "token" ? ( -
- - - - - - - - - -
+ modelCapability === "video" ? ( +
+ + + +
仅火山方舟视频协议可用;成功后按任务查询响应的 usage.completion_tokens 结算。
+
+ ) : ( +
+ + + + + + + + + +
+ ) ) : ( @@ -303,9 +315,7 @@ function billingSummary(item: ChannelModel) { } return (
-
输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万
-
输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万
-
缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万
+ {item.capability === "video" ?
视频 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万
: <>
输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万
输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万
缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万
}
); } diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index 02604c943..39086fe15 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -6,7 +6,7 @@ import { testChannelModelConnection } from "@/lib/model-connection-test"; import { ModelCapabilityEditor } from "@/components/model-capability-editor"; import { CapabilityCardPicker, ProtocolCardPicker } from "@/components/model-protocol-picker"; import { defaultModelCapabilityConfig } from "@/lib/model-capabilities"; -import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, type ModelProtocol } from "@/lib/model-protocols"; +import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols"; import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store"; type ModelCost = NonNullable[number]; @@ -46,6 +46,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan const activeProtocol = activeModel ? activeModelCost?.protocol || defaultProtocolForModel(channel, activeModel) : undefined; const activeCapability = activeModel ? activeModelCost?.capability || modelProtocolCapability(activeProtocol) || "text" : undefined; const activeBillingMode = activeModelCost?.billingMode || "fixed_request"; + const activeTokenBillingSupported = modelProtocolSupportsTokenBilling(activeCapability, activeProtocol); return (
@@ -124,7 +125,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan updateCost(activeModel, { protocol: nextProtocol, capability: nextCapability, - billingMode: nextCapability === "video" ? activeBillingMode : "fixed_request", + billingMode: activeBillingMode === "per_second" && nextCapability === "video" ? "per_second" : activeBillingMode === "token" && modelProtocolSupportsTokenBilling(nextCapability, nextProtocol) ? "token" : "fixed_request", capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, }); }} @@ -135,7 +136,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan updateCost(activeModel, { protocol: nextProtocol, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })} + onChange={(nextProtocol) => updateCost(activeModel, { protocol: nextProtocol, billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode, capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined })} /> {activeCapability === "video" ? ( @@ -149,6 +150,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan options={[ { label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }, + { label: "Token", value: "token", disabled: !activeTokenBillingSupported }, ]} onChange={(value) => updateCost(activeModel, { billingMode: value as ModelCost["billingMode"] })} /> @@ -159,12 +161,13 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan precision={6} step={0.1} className="w-full" - placeholder={activeBillingMode === "per_second" ? "每秒价格" : "每次价格"} - addonAfter={`积分/${activeBillingMode === "per_second" ? "秒" : "次"}`} - value={activeModelCost ? activeModelCost.unitPriceMicrocredits / 1_000_000 : null} - onChange={(value) => updateCost(activeModel, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} + placeholder={activeBillingMode === "token" ? "每百万视频 Token 价格" : activeBillingMode === "per_second" ? "每秒价格" : "每次价格"} + addonAfter={`积分/${activeBillingMode === "token" ? "百万 Token" : activeBillingMode === "per_second" ? "秒" : "次"}`} + value={activeModelCost ? (activeBillingMode === "token" ? (activeModelCost.outputTokenPriceMicrocredits || 0) : activeModelCost.unitPriceMicrocredits) / 1_000_000 : null} + onChange={(value) => updateCost(activeModel, activeBillingMode === "token" ? { outputTokenPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) } : { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} />
+ {activeBillingMode === "token" ?
按火山方舟任务查询响应的 usage.completion_tokens 结算。
: null} ) : null} {activeCapability === "image" || activeCapability === "video" ? ( diff --git a/web/src/pages/settings/model-default-grid.tsx b/web/src/pages/settings/model-default-grid.tsx index 005c66ff0..d9e0be239 100644 --- a/web/src/pages/settings/model-default-grid.tsx +++ b/web/src/pages/settings/model-default-grid.tsx @@ -71,7 +71,7 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha {channel.name || "未命名渠道"} {channel.scope === "system" ? "系统" : "自定义"} - {creditsEnabled && cost ? {formatPrice(cost.unitPriceMicrocredits)} /{cost.billingMode === "per_second" ? "秒" : "次"} : null} + {creditsEnabled && cost ? {formatPrice(cost.billingMode === "token" ? (cost.outputTokenPriceMicrocredits || 0) : cost.unitPriceMicrocredits)} /{cost.billingMode === "token" ? "百万 Token" : cost.billingMode === "per_second" ? "秒" : "次"} : null}