fix(billing): 视频模型计费 - 合并 PR #285 并支持火山方舟 Token 定价
This commit is contained in:
commit
8be5334d9c
6 files changed
+98
-18
No files matched your search
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- 前台火山方舟视频模型支持统一 Token 定价,并在混用其他视频协议或线路协议变更时阻止不兼容计费。
|
||||||
|
|
||||||
## v1.1.4
|
## v1.1.4
|
||||||
|
|
||||||
- 修复 `/canvas` 路由与静态模型目录同名导致的容器端口跳转,确保域名访问保持原有 HTTPS 地址。
|
- 修复 `/canvas` 路由与静态模型目录同名导致的容器端口跳转,确保域名访问保持原有 HTTPS 地址。
|
||||||
|
|||||||
@@ -498,6 +498,9 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i
|
|||||||
}
|
}
|
||||||
amount, err = creditAmount(logicalModel.UnitPriceMicrocredits, quantity, 10_000)
|
amount, err = creditAmount(logicalModel.UnitPriceMicrocredits, quantity, 10_000)
|
||||||
case "token":
|
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}
|
pricing := &model.ChannelModel{InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits}
|
||||||
amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000)
|
amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000)
|
||||||
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
|
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
|
||||||
|
|||||||
@@ -278,7 +278,8 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
|
|||||||
}
|
}
|
||||||
_, channelOK = systemChannelByID[channelModel.ChannelID]
|
_, channelOK = systemChannelByID[channelModel.ChannelID]
|
||||||
structurallyAvailable := route.Enabled && route.Weight > 0 && channelModel.Enabled && channelOK
|
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})
|
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))
|
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" {
|
if pricePolicy == "unified" && billingMode == "per_second" && capability != "video" {
|
||||||
return nil, nil, nil, false, BadAuthRequest("只有视频前台模型可以按秒计费")
|
return nil, nil, nil, false, BadAuthRequest("只有视频前台模型可以按秒计费")
|
||||||
}
|
}
|
||||||
if pricePolicy == "unified" && billingMode == "token" && capability != "text" {
|
|
||||||
return nil, nil, nil, false, BadAuthRequest("当前仅文本前台模型支持 Token 计费")
|
|
||||||
}
|
|
||||||
creating := strings.TrimSpace(id) == ""
|
creating := strings.TrimSpace(id) == ""
|
||||||
var item *model.LogicalModel
|
var item *model.LogicalModel
|
||||||
if creating {
|
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))
|
seenChannelModels := make(map[string]bool, len(req.Routes))
|
||||||
structuralRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes))
|
structuralRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes))
|
||||||
settlementRouteSpecs := 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 {
|
for _, input := range req.Routes {
|
||||||
channelModelID := strings.TrimSpace(input.ChannelModelID)
|
channelModelID := strings.TrimSpace(input.ChannelModelID)
|
||||||
if channelModelID == "" || seenChannelModels[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 {
|
if normalizeCapability(capabilitySpec.Capability) != capability {
|
||||||
return nil, nil, nil, false, BadAuthRequest("供应线路能力类型与前台模型不一致")
|
return nil, nil, nil, false, BadAuthRequest("供应线路能力类型与前台模型不一致")
|
||||||
}
|
}
|
||||||
|
if input.Enabled {
|
||||||
|
enabledRouteProtocols = append(enabledRouteProtocols, channelModel.Protocol)
|
||||||
|
}
|
||||||
if req.Enabled && input.Enabled && input.Weight <= 0 {
|
if req.Enabled && input.Enabled && input.Weight <= 0 {
|
||||||
return nil, nil, nil, false, BadAuthRequest("启用供应线路的同级权重必须大于 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()})
|
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 req.Enabled {
|
||||||
if len(structuralRouteSpecs) == 0 {
|
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
|
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) {
|
func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map[string]any, error) {
|
||||||
result := make(map[string]any, len(defaults))
|
result := make(map[string]any, len(defaults))
|
||||||
for rawName, value := range defaults {
|
for rawName, value := range defaults {
|
||||||
|
|||||||
@@ -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) {
|
func TestChannelModelCapabilitySpecRequiresExplicitImageCapability(t *testing.T) {
|
||||||
channelModel := model.ChannelModel{Capability: "image", CapabilityConfigJSON: ""}
|
channelModel := model.ChannelModel{Capability: "image", CapabilityConfigJSON: ""}
|
||||||
|
|
||||||
|
|||||||
@@ -537,6 +537,9 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
|
|||||||
if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] {
|
if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if item.PricePolicy == "channel" && !channelModel.PriceConfigured {
|
if item.PricePolicy == "channel" && !channelModel.PriceConfigured {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -840,6 +843,9 @@ func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, e
|
|||||||
if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured {
|
if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured {
|
||||||
return nil, errors.New("任务使用的模型服务价格配置已失效")
|
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}
|
routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}
|
||||||
return routed, nil
|
return routed, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PaginationBar } from "@/components/layout/workspace-page";
|
|||||||
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
|
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
|
||||||
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
|
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
|
||||||
import { formatCredits } from "@/constant/credits";
|
import { formatCredits } from "@/constant/credits";
|
||||||
|
import { modelProtocolSupportsTokenBilling } from "@/lib/model-protocols";
|
||||||
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
|
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
|
||||||
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
|
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
|
||||||
import { listAdminChannels } from "@/services/api/auth";
|
import { listAdminChannels } from "@/services/api/auth";
|
||||||
@@ -416,7 +417,7 @@ export default function LogicalModelsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
<DrawerSection title="用户价格">
|
<DrawerSection title="用户价格">
|
||||||
<PricingFields />
|
<PricingFields channelModels={channelModels} />
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -599,18 +600,38 @@ function logicalModelStatusTag(item: AdminLogicalModel) {
|
|||||||
return <AdminStatusBadge label="可用" tone="success" />;
|
return <AdminStatusBadge label="可用" tone="success" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PricingFields() {
|
function PricingFields({ channelModels }: { channelModels: ChannelModel[] }) {
|
||||||
const form = Form.useFormInstance<LogicalModelFormValues>();
|
const form = Form.useFormInstance<LogicalModelFormValues>();
|
||||||
const pricePolicy = Form.useWatch("pricePolicy") as LogicalModelMutation["pricePolicy"] | undefined;
|
const pricePolicy = Form.useWatch("pricePolicy") as LogicalModelMutation["pricePolicy"] | undefined;
|
||||||
const billingMode = Form.useWatch("billingMode") as LogicalModelMutation["billingMode"] | undefined;
|
const billingMode = Form.useWatch("billingMode") as LogicalModelMutation["billingMode"] | undefined;
|
||||||
const capability = Form.useWatch("capability") as CapabilityKind | undefined;
|
const capability = Form.useWatch("capability") as CapabilityKind | undefined;
|
||||||
const modes = [{ label: "按次", value: "fixed_request" }];
|
const routes = (Form.useWatch("routes") || []) as RouteRuleRow[];
|
||||||
if (capability === "video") modes.push({ label: "按秒", value: "per_second" });
|
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" });
|
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 changePolicy = (value: string | number) => {
|
||||||
const nextPolicy = value as LogicalModelMutation["pricePolicy"];
|
const nextPolicy = value as LogicalModelMutation["pricePolicy"];
|
||||||
if (nextPolicy !== "unified") return;
|
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)) {
|
if (!billingMode || !supportedModes.includes(billingMode)) {
|
||||||
form.setFieldValue("billingMode", capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request");
|
form.setFieldValue("billingMode", capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request");
|
||||||
}
|
}
|
||||||
@@ -639,18 +660,25 @@ function PricingFields() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{capability === "video" && !tokenBillingSupported ? <div className="mb-3 text-xs leading-5 text-foreground/50">Token 计费仅在所有启用供应线路都使用火山方舟视频协议时可用。</div> : null}
|
||||||
{billingMode === "token" ? (
|
{billingMode === "token" ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
capability === "video" ? (
|
||||||
<Form.Item name="inputPriceMicrocredits" label="输入 / 百万 Token">
|
<Form.Item name="outputPriceMicrocredits" label="视频 / 百万 Token" rules={[{ type: "number", min: 1, message: "请输入视频 Token 价格" }]}>
|
||||||
<CreditsInput />
|
<CreditsInput />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="outputPriceMicrocredits" label="输出 / 百万 Token">
|
) : (
|
||||||
<CreditsInput />
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
</Form.Item>
|
<Form.Item name="inputPriceMicrocredits" label="输入 / 百万 Token">
|
||||||
<Form.Item name="cachedPriceMicrocredits" label="缓存 / 百万 Token">
|
<CreditsInput />
|
||||||
<CreditsInput />
|
</Form.Item>
|
||||||
</Form.Item>
|
<Form.Item name="outputPriceMicrocredits" label="输出 / 百万 Token">
|
||||||
</div>
|
<CreditsInput />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cachedPriceMicrocredits" label="缓存 / 百万 Token">
|
||||||
|
<CreditsInput />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -666,6 +694,7 @@ function CreditsInput({ value = 0, onChange }: { value?: number; onChange?: (val
|
|||||||
|
|
||||||
function logicalPriceLabel(item: AdminLogicalModel) {
|
function logicalPriceLabel(item: AdminLogicalModel) {
|
||||||
if (item.pricePolicy === "channel") return <span className="text-xs">跟随供应价格</span>;
|
if (item.pricePolicy === "channel") return <span className="text-xs">跟随供应价格</span>;
|
||||||
|
if (item.billingMode === "token" && item.capability === "video") return <span className="text-xs">视频 {formatCredits(item.outputPriceMicrocredits)} / 百万 Token</span>;
|
||||||
if (item.billingMode === "token")
|
if (item.billingMode === "token")
|
||||||
return (
|
return (
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
|
|||||||
Reference in new issue
Block a user