fix(api): Token 账单 - 按真实用量结算预授权差额 (#145)

Closes #145
This commit is contained in:
ddCat
2026-08-05 17:06:07 +08:00
parent 4721c1e97f
commit cd85a45936
11 changed files with 402 additions and 89 deletions

View File

@@ -11,7 +11,7 @@ export function CreditSymbol({ className, ...props }: ComponentProps<"span">) {
export type ModelCreditCost = {
model: string;
billingMode: "fixed_request" | "per_second";
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
};
@@ -27,6 +27,8 @@ export function requestCreditCost(options: { channelMode: string; modelCosts?: M
if (options.channelMode !== "remote") return null;
const cost = modelCreditCost(options.modelCosts, options.model);
if (!cost) return null;
// Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。
if (cost.billingMode === "token") return null;
const quantity = cost.billingMode === "per_second"
? Math.max(1, Math.floor(Math.abs(Number(options.seconds)) || 1))
: Math.max(1, Math.floor(Math.abs(Number(options.count)) || 1));

View File

@@ -20,6 +20,9 @@ type FormValues = {
protocol: ModelProtocol;
billingMode: ChannelModel["billingMode"];
unitPrice: number;
inputTokenPrice: number;
outputTokenPrice: number;
cachedTokenPrice: number;
enabled: boolean;
};
@@ -83,13 +86,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const startCreate = () => {
setEditing(null);
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, enabled: true });
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true });
setEditorOpen(true);
};
const startEdit = (item: ChannelModel) => {
setEditing(item);
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, enabled: item.enabled });
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled });
setEditorOpen(true);
};
@@ -104,6 +107,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
protocol: values.protocol,
billingMode: values.billingMode,
unitPriceMicrocredits: Math.round(values.unitPrice * 1_000_000),
inputTokenPriceMicrocredits: Math.round((values.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((values.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
priceConfigured: true,
enabled: values.enabled !== false,
};
@@ -151,7 +157,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const handleFormValuesChange = (changed: Partial<FormValues>) => {
if (!changed.capability) return;
if (changed.capability !== "video") form.setFieldValue("billingMode", "fixed_request");
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) {
form.setFieldValue("billingMode", "fixed_request");
}
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
if (modelProtocolCapability(current) !== changed.capability) {
form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value);
@@ -173,7 +182,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
},
{ title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel },
{ title: "请求协议", dataIndex: "protocol", width: 230, render: (value: ModelProtocol) => value ? <div><div className="text-xs font-medium">{modelProtocolLabel(value)}</div><div className="truncate text-[var(--fs-tiny)] text-foreground/45">{modelProtocolDefinition(value)?.create}</div></div> : <Tag color="orange">待配置</Tag> },
{ title: "计费", width: 165, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}` : <Tag color="orange">未配置价格</Tag>) },
{ title: "计费", width: 220, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <Tag color="orange">未配置价格</Tag>) },
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => (enabled ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>) },
{
@@ -238,11 +247,25 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<ProtocolCardPicker capability={modelCapability} />
</Form.Item>
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
</Form.Item>
<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} />
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
</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>
) : (
<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} />
</Form.Item>
)}
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
@@ -375,6 +398,19 @@ function capabilityLabel(value: ChannelModel["capability"]) {
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
}
function billingSummary(item: ChannelModel) {
if (item.billingMode !== "token") {
return `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}`;
}
return (
<div className="text-xs leading-5">
<div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div>
<div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
<div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div>
</div>
);
}
function formatCredits(value: number) {
return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
}

View File

@@ -195,7 +195,17 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
</div>
),
},
{ title: "冻结积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => <span className="font-medium tabular-nums">{formatCredits(value)}</span> },
{ title: "预授权积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => <span className="font-medium tabular-nums">{formatCredits(value)}</span> },
{
title: "实际结算 / 用量",
width: 190,
render: (_, order) => order.billingMode === "token" ? (
<div className="text-xs leading-5">
<div className="font-medium tabular-nums">{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "待 usage 结算"}</div>
<div className="text-foreground/50">输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}</div>
</div>
) : <span className="tabular-nums">{order.status === "settled" ? formatCredits(order.actualAmountMicrocredits || order.amountMicrocredits) : "--"}</span>,
},
{
title: "状态",
dataIndex: "status",
@@ -414,7 +424,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
setPageSize(nextPageSize);
},
}}
scroll={{ x: 1200 }}
scroll={{ x: 1390 }}
/>
</TableSurface>
</section>

View File

@@ -66,8 +66,11 @@ export type ChannelModel = {
displayName: string;
capability: "text" | "image" | "video" | "audio" | "";
protocol?: import("@/lib/model-protocols").ModelProtocol;
billingMode: "fixed_request" | "per_second";
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
priceConfigured: boolean;
enabled: boolean;
priceVersion: number;
@@ -161,11 +164,21 @@ export type BillingOrder = {
model: string;
capability: string;
scene: string;
billingMode: "fixed_request" | "per_second";
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
multiplierBasisPoints: number;
quantity: number;
amountMicrocredits: number;
reservedAmountMicrocredits: number;
actualAmountMicrocredits: number;
refundedAmountMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
usageAvailable: boolean;
status: "reserved" | "running" | "settled" | "refunded" | "uncertain";
providerRequestId?: string;
error?: string;

View File

@@ -31,8 +31,11 @@ export type ModelChannel = {
displayName?: string;
capability: ModelCapability;
protocol?: ModelProtocol;
billingMode: "fixed_request" | "per_second";
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits?: number;
outputTokenPriceMicrocredits?: number;
cachedTokenPriceMicrocredits?: number;
}>;
};