diff --git a/CHANGELOG.md b/CHANGELOG.md index c6497773d..d11294aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 前台火山方舟视频模型支持统一 Token 定价,并在混用其他视频协议或线路协议变更时阻止不兼容计费。 + ## v1.1.4 - 修复 `/canvas` 路由与静态模型目录同名导致的容器端口跳转,确保域名访问保持原有 HTTPS 地址。 diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 44dce5b9c..30e83c6fc 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -498,6 +498,9 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i } amount, err = creditAmount(logicalModel.UnitPriceMicrocredits, quantity, 10_000) case "token": + if channelModel.Capability != capability || !supportsTokenBilling(capability, channelModel.Protocol) { + return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式") + } pricing := &model.ChannelModel{InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits} amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000) quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 8be0840da..6fd85ffd4 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -278,7 +278,8 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit } _, channelOK = systemChannelByID[channelModel.ChannelID] structurallyAvailable := route.Enabled && route.Weight > 0 && channelModel.Enabled && channelOK - available := structurallyAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured) + billingAvailable := item.PricePolicy != "unified" || item.BillingMode != "token" || supportsTokenBilling(item.Capability, channelModel.Protocol) + available := structurallyAvailable && billingAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured) admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, ChannelModelKey: channelModel.ModelKey, ChannelModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: capabilitySpec}) } routeSpecs := make([]CapabilitySpec, 0, len(admin.Routes)) @@ -479,9 +480,6 @@ 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" && capability != "text" { - return nil, nil, nil, false, BadAuthRequest("当前仅文本前台模型支持 Token 计费") - } creating := strings.TrimSpace(id) == "" var item *model.LogicalModel if creating { @@ -514,6 +512,7 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo seenChannelModels := make(map[string]bool, len(req.Routes)) structuralRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes)) settlementRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes)) + enabledRouteProtocols := make([]model.ChannelInterfaceType, 0, len(req.Routes)) for _, input := range req.Routes { channelModelID := strings.TrimSpace(input.ChannelModelID) if channelModelID == "" || seenChannelModels[channelModelID] { @@ -531,6 +530,9 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if normalizeCapability(capabilitySpec.Capability) != capability { return nil, nil, nil, false, BadAuthRequest("供应线路能力类型与前台模型不一致") } + if input.Enabled { + enabledRouteProtocols = append(enabledRouteProtocols, channelModel.Protocol) + } if req.Enabled && input.Enabled && input.Weight <= 0 { return nil, nil, nil, false, BadAuthRequest("启用供应线路的同级权重必须大于 0") } @@ -552,6 +554,14 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo } routes = append(routes, model.LogicalModelRoute{ID: routeID, ChannelModelID: channelModel.ID, Enabled: input.Enabled, Priority: input.Priority, Weight: input.Weight, CreatedAt: time.Now(), UpdatedAt: time.Now()}) } + if pricePolicy == "unified" && billingMode == "token" { + if !supportsLogicalModelTokenBilling(capability, enabledRouteProtocols) { + return nil, nil, nil, false, BadAuthRequest("Token 计费仅支持文本前台模型,或全部启用供应线路均为火山方舟视频协议的视频前台模型") + } + if capability == "video" && req.OutputPriceMicrocredits <= 0 { + return nil, nil, nil, false, BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格") + } + } // 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。 if req.Enabled { if len(structuralRouteSpecs) == 0 { @@ -567,6 +577,21 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo return item, revision, routes, creating, nil } +func supportsLogicalModelTokenBilling(capability string, enabledRouteProtocols []model.ChannelInterfaceType) bool { + if capability == "text" { + return true + } + if capability != "video" || len(enabledRouteProtocols) == 0 { + return false + } + for _, protocol := range enabledRouteProtocols { + if !supportsTokenBilling(capability, protocol) { + return false + } + } + return true +} + func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map[string]any, error) { result := make(map[string]any, len(defaults)) for rawName, value := range defaults { diff --git a/backend/internal/service/logical_models_test.go b/backend/internal/service/logical_models_test.go index 748a22ee6..5af096129 100644 --- a/backend/internal/service/logical_models_test.go +++ b/backend/internal/service/logical_models_test.go @@ -150,6 +150,21 @@ func TestLogicalModelAvailabilityErrorRequiresSettlementReadyCoverage(t *testing } } +func TestSupportsLogicalModelTokenBillingForArkVideoRoutes(t *testing.T) { + if !supportsLogicalModelTokenBilling("text", nil) { + t.Fatal("text logical models should support Token billing") + } + if !supportsLogicalModelTokenBilling("video", []model.ChannelInterfaceType{model.ChannelInterfaceVolcengineArkVideo}) { + t.Fatal("Ark video-only routes should support Token billing") + } + 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") + } +} + func TestChannelModelCapabilitySpecRequiresExplicitImageCapability(t *testing.T) { channelModel := model.ChannelModel{Capability: "image", CapabilityConfigJSON: ""} diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index 624576157..e0e3db43f 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -537,6 +537,9 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { continue } + if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { + continue + } if item.PricePolicy == "channel" && !channelModel.PriceConfigured { continue } @@ -840,6 +843,9 @@ func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, e if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { return nil, errors.New("任务使用的模型服务价格配置已失效") } + if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { + return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") + } routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} return routed, nil } diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index 9319f1aad..ee4260a5f 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -8,6 +8,7 @@ import { PaginationBar } from "@/components/layout/workspace-page"; import { ModelIconPicker, ModelLogo } from "@/components/model-logo"; import { CapabilityCardPicker } from "@/components/model-protocol-picker"; import { formatCredits } from "@/constant/credits"; +import { modelProtocolSupportsTokenBilling } from "@/lib/model-protocols"; import { AdminPageFrame } from "@/pages/admin/components/admin-shell"; import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui"; import { listAdminChannels } from "@/services/api/auth"; @@ -416,7 +417,7 @@ export default function LogicalModelsPage() { - + @@ -599,18 +600,38 @@ function logicalModelStatusTag(item: AdminLogicalModel) { return ; } -function PricingFields() { +function PricingFields({ channelModels }: { channelModels: ChannelModel[] }) { const form = Form.useFormInstance(); const pricePolicy = Form.useWatch("pricePolicy") as LogicalModelMutation["pricePolicy"] | undefined; const billingMode = Form.useWatch("billingMode") as LogicalModelMutation["billingMode"] | undefined; const capability = Form.useWatch("capability") as CapabilityKind | undefined; - const modes = [{ label: "按次", value: "fixed_request" }]; - if (capability === "video") modes.push({ label: "按秒", value: "per_second" }); + const routes = (Form.useWatch("routes") || []) as RouteRuleRow[]; + const enabledRoutes = routes.filter((route) => route.enabled); + const tokenBillingSupported = + capability === "text" || + (capability === "video" && + enabledRoutes.length > 0 && + enabledRoutes.every((route) => { + const channelModel = channelModels.find((item) => item.id === route.channelModelId); + return modelProtocolSupportsTokenBilling(channelModel?.capability, channelModel?.protocol); + })); + const modes: Array<{ label: string; value: LogicalModelMutation["billingMode"]; disabled?: boolean }> = [{ label: "按次", value: "fixed_request" }]; + if (capability === "video") { + modes.push({ label: "按秒", value: "per_second" }); + modes.push({ label: "Token", value: "token", disabled: !tokenBillingSupported }); + } if (capability === "text") modes.push({ label: "Token", value: "token" }); + + useEffect(() => { + if (pricePolicy === "unified" && billingMode === "token" && !tokenBillingSupported) { + form.setFieldValue("billingMode", capability === "video" ? "per_second" : "fixed_request"); + } + }, [billingMode, capability, form, pricePolicy, tokenBillingSupported]); + const changePolicy = (value: string | number) => { const nextPolicy = value as LogicalModelMutation["pricePolicy"]; if (nextPolicy !== "unified") return; - const supportedModes = modes.map((item) => item.value); + const supportedModes = modes.filter((item) => !item.disabled).map((item) => item.value); if (!billingMode || !supportedModes.includes(billingMode)) { form.setFieldValue("billingMode", capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request"); } @@ -639,18 +660,25 @@ function PricingFields() { ) : null} + {capability === "video" && !tokenBillingSupported ?
Token 计费仅在所有启用供应线路都使用火山方舟视频协议时可用。
: null} {billingMode === "token" ? ( -
- + capability === "video" ? ( + - - - - - - -
+ ) : ( +
+ + + + + + + + + +
+ ) ) : null} ) : ( @@ -666,6 +694,7 @@ function CreditsInput({ value = 0, onChange }: { value?: number; onChange?: (val function logicalPriceLabel(item: AdminLogicalModel) { if (item.pricePolicy === "channel") return 跟随供应价格; + if (item.billingMode === "token" && item.capability === "video") return 视频 {formatCredits(item.outputPriceMicrocredits)} / 百万 Token; if (item.billingMode === "token") return (