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

@@ -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")}>