From acd5e7badf16bdae521226527ff2f53d123d5e0c Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:47:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(billing):=20=E7=81=AB=E5=B1=B1=E6=96=B9?= =?UTF-8?q?=E8=88=9F=E8=A7=86=E9=A2=91=20-=20=E6=81=A2=E5=A4=8D=20Token=20?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- backend/internal/service/model_catalog.go | 2 +- backend/internal/service/price_validation.go | 19 +++++------ .../internal/service/price_validation_test.go | 32 +++++++++++++++++++ web/package.json | 2 +- web/src/lib/model-protocols.ts | 4 ++- web/test/model-protocols.test.ts | 13 ++++++++ 7 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 backend/internal/service/price_validation_test.go create mode 100644 web/test/model-protocols.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d11294aab..7d9f57934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- 前台火山方舟视频模型支持统一 Token 定价,并在混用其他视频协议或线路协议变更时阻止不兼容计费。 +- 系统渠道和前台火山方舟视频模型支持 Token 定价,并在混用其他视频协议或线路协议变更时阻止不兼容计费。 ## v1.1.4 diff --git a/backend/internal/service/model_catalog.go b/backend/internal/service/model_catalog.go index 49fa66b91..f0f62d36a 100644 --- a/backend/internal/service/model_catalog.go +++ b/backend/internal/service/model_catalog.go @@ -144,7 +144,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelMode // 转换为公开的价格档 publicTiers := make([]PublicChannelModelPriceTier, 0, len(priceTiers)) for _, tier := range priceTiers { - if !tier.Enabled || !ValidatePriceTierPrice(&tier) { + if !tier.Enabled || !ValidatePriceTierPrice(&tier, cm.Capability, cm.Protocol) { continue } publicTiers = append(publicTiers, PublicChannelModelPriceTier{ diff --git a/backend/internal/service/price_validation.go b/backend/internal/service/price_validation.go index 57e1aae91..2426936a4 100644 --- a/backend/internal/service/price_validation.go +++ b/backend/internal/service/price_validation.go @@ -13,10 +13,11 @@ func ValidateChannelModelPrice(billingMode string, capability string, protocol m // 按秒计费:必须配置单价且大于 0 return unitPrice > 0 case "token": - // Token 计费:根据协议要求校验 - if capability == "video" && (protocol == model.ChannelInterfaceVolcengineArkVideo || protocol == model.ChannelInterfaceVolcengineJiMengVideo) { - // 火山方舟视频必须配置输出价格 - return outputPrice > 0 + if capability == "video" { + return protocol == model.ChannelInterfaceVolcengineArkVideo && outputPrice > 0 + } + if capability != "" && capability != "text" { + return false } // 文本模型:至少配置一项价格 return inputPrice > 0 || outputPrice > 0 || cachedPrice > 0 @@ -26,11 +27,11 @@ func ValidateChannelModelPrice(billingMode string, capability string, protocol m } // ValidatePriceTierPrice 校验价格档的价格配置有效性 -func ValidatePriceTierPrice(tier *model.ChannelModelPriceTier) bool { +func ValidatePriceTierPrice(tier *model.ChannelModelPriceTier, capability string, protocol model.ChannelInterfaceType) bool { if tier == nil { return false } - return ValidateChannelModelPrice(tier.BillingMode, "", "", tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) + return ValidateChannelModelPrice(tier.BillingMode, capability, protocol, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) } // ComputePriceConfigured 根据价格内容计算 PriceConfigured 标志 @@ -40,11 +41,11 @@ func ComputePriceConfigured(billingMode string, capability string, protocol mode } // ComputeTierPriceConfigured 计算价格档的 PriceConfigured 标志 -func ComputeTierPriceConfigured(tier *model.ChannelModelPriceTier) bool { +func ComputeTierPriceConfigured(tier *model.ChannelModelPriceTier, capability string, protocol model.ChannelInterfaceType) bool { if tier == nil { return false } - return ComputePriceConfigured(tier.BillingMode, "", "", tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) + return ComputePriceConfigured(tier.BillingMode, capability, protocol, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) } // HasValidPrice 检查渠道模型是否有有效价格 @@ -57,7 +58,7 @@ func HasValidPrice(channelModel *model.ChannelModel) bool { // 如果有价格档,检查价格档 if len(channelModel.PriceTiers) > 0 { for _, tier := range channelModel.PriceTiers { - if tier.Enabled && ValidatePriceTierPrice(&tier) { + if tier.Enabled && ValidatePriceTierPrice(&tier, channelModel.Capability, channelModel.Protocol) { return true } } diff --git a/backend/internal/service/price_validation_test.go b/backend/internal/service/price_validation_test.go new file mode 100644 index 000000000..74bd2369f --- /dev/null +++ b/backend/internal/service/price_validation_test.go @@ -0,0 +1,32 @@ +package service + +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) + +func TestValidateChannelModelPriceSupportsArkVideoTokensOnly(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) + } + } +} + +func TestHasValidPriceUsesChannelProtocolForTokenTiers(t *testing.T) { + tier := model.ChannelModelPriceTier{Enabled: true, BillingMode: "token", OutputTokenPriceMicrocredits: 16_000_000} + ark := &model.ChannelModel{Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, PriceTiers: []model.ChannelModelPriceTier{tier}} + if !HasValidPrice(ark) { + t.Fatal("Volcengine Ark video Token tier should be valid") + } + + jimeng := &model.ChannelModel{Capability: "video", Protocol: model.ChannelInterfaceVolcengineJiMengVideo, PriceTiers: []model.ChannelModelPriceTier{tier}} + if HasValidPrice(jimeng) { + t.Fatal("JiMeng video Token tier should be invalid") + } +} diff --git a/web/package.json b/web/package.json index b19618375..4f9be7a5f 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index c7db65153..5f303a860 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -8,7 +8,9 @@ export function protocolGroups(protocols: ModelProtocolDefinition[]) { export function modelProtocolDefinition(value: string | undefined, definitions: ModelProtocolDefinition[] = []) { return definitions.find((item) => item.value === value); } export function modelProtocolLabel(value: string | undefined, definitions: ModelProtocolDefinition[] = []) { return modelProtocolDefinition(value, definitions)?.label || (value ? value : "未安装协议"); } export function modelProtocolCapability(value: string | undefined, definitions: ModelProtocolDefinition[] = []) { return modelProtocolDefinition(value, definitions)?.capability; } -export function modelProtocolSupportsTokenBilling(capability?: string, _protocol?: string) { return capability === "text"; } +export function modelProtocolSupportsTokenBilling(capability?: string, protocol?: string) { + return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video"); +} export function protocolForModelCatalog(_endpointTypes: string[] = []): ModelProtocol | undefined { // A provider catalog cannot invent a protocol ID. The channel's selected diff --git a/web/test/model-protocols.test.ts b/web/test/model-protocols.test.ts new file mode 100644 index 000000000..6cccde9f7 --- /dev/null +++ b/web/test/model-protocols.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; + +import { modelProtocolSupportsTokenBilling } from "../src/lib/model-protocols"; + +describe("model protocol Token billing", () => { + test("supports text models and Volcengine Ark video only", () => { + expect(modelProtocolSupportsTokenBilling("text", "chat-completion")).toBe(true); + expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-video")).toBe(true); + expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(false); + expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(false); + expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-image")).toBe(false); + }); +});