feat(billing): 火山方舟视频计费 - 支持真实 Token 预授权与结算

Refs #228

Co-authored-by: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com>
This commit is contained in:
ddCat
2026-08-15 12:58:05 +08:00
parent 476348a322
commit b2a7eef35d
11 changed files with 362 additions and 36 deletions

View File

@@ -276,11 +276,16 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
}
function modelMenuPrice(config: AiConfig, model: string): { value: number; unit: "次" | "秒" } | null | undefined {
type ModelMenuPrice = { value: number; unit: "次" | "秒" | "百万 Token" };
function modelMenuPrice(config: AiConfig, model: string): ModelMenuPrice | null | undefined {
if (!model) return undefined;
const channel = resolveModelChannel(config, model);
const cost = channel.modelCosts?.find((item) => 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 : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
return (

View File

@@ -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";

View File

@@ -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<FormValues>();
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:
</Form.Item>
{modelCapability === "image" || modelCapability === "video" ? <Form.Item name="capabilityConfig" rules={[{ required: true, message: `请配置${modelCapability === "image" ? "图片" : "视频"}能力参数` }]}><ModelCapabilityEditor capability={modelCapability} model={modelKey} protocol={form.getFieldValue("protocol")} /></Form.Item> : null}
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: !modelProtocolSupportsTokenBilling(modelCapability, modelProtocol) }]} />
</Form.Item>
{billingMode === "token" ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</div>
modelCapability === "video" ? (
<div>
<Form.Item name="outputTokenPrice" label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
<InputNumber style={{ width: "100%" }} min={0.000001} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<div className="mb-4 text-xs text-foreground/45">仅火山方舟视频协议可用;成功后按任务查询响应的 usage.completion_tokens 结算。</div>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</div>
)
) : (
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
@@ -303,9 +315,7 @@ function billingSummary(item: ChannelModel) {
}
return (
<div className="text-xs leading-5">
<div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div>
<div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
<div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div>
{item.capability === "video" ? <div>视频 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div> : <><div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div><div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div><div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div></>}
</div>
);
}

View File

@@ -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<ModelChannel["modelCosts"]>[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 (
<div className="mt-4">
@@ -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
<ProtocolCardPicker
capability={activeCapability}
value={activeProtocol}
onChange={(nextProtocol) => 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 })}
/>
</section>
{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) })}
/>
</div>
{activeBillingMode === "token" ? <div className="text-[var(--fs-tiny)] text-foreground/45">按火山方舟任务查询响应的 usage.completion_tokens 结算。</div> : null}
</div>
) : null}
{activeCapability === "image" || activeCapability === "video" ? (

View File

@@ -71,7 +71,7 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
<span className="model-default-option-scope">{channel.scope === "system" ? "系统" : "自定义"}</span>
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.unitPriceMicrocredits)} /{cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.billingMode === "token" ? (cost.outputTokenPriceMicrocredits || 0) : cost.unitPriceMicrocredits)} /{cost.billingMode === "token" ? "百万 Token" : cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
</span>
</span>
<span className={cn("model-default-option-check grid size-5 shrink-0 place-items-center rounded-full", selected ? "is-selected" : "text-transparent")}>