feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示

This commit is contained in:
ddCat
2026-09-18 23:26:54 +08:00
parent 90add8c466
commit 1a79005d25
57 changed files with 2904 additions and 341 deletions

View File

@@ -0,0 +1,64 @@
import { configuredModelDisplayName, groupModelsByDisplayName, type DisplayModelGroup } from "@/lib/model-selection";
import { modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
export type ModelPickerGroup = {
key: string;
label: string;
icon: string;
scope: string;
kind: "product" | "channel";
models: DisplayModelGroup[];
};
export function isDirectSystemModel(config: AiConfig, value: string) {
if (!value) return false;
const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
return channel.scope === "system" && channel.id !== PUBLIC_MODEL_CATALOG_ID && !cost?.logicalModelId;
}
export function modelChannelLabel(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
return cost?.channelLabel?.trim() || channel.publicAlias?.trim() || channel.name || "未命名渠道";
}
// 只构建展示树。每个产品下的叶子仍是独立的渠道选择,不能交给模型族自动路由。
export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] {
const groups = new Map<string, ModelPickerGroup>();
for (const channel of config.channels) {
const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id);
for (const value of models) {
if (!isDirectSystemModel(config, value)) continue;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
const key = JSON.stringify(["product", cost?.capability, modelOptionName(value)]);
let group = groups.get(key);
if (!group) {
group = { key, label: "", icon: "", scope: "平台服务", kind: "product", models: [] };
groups.set(key, group);
}
group.models.push({ key: value, label: modelChannelLabel(config, value), models: [value] });
}
const otherModels = models.filter((value) => !isDirectSystemModel(config, value));
if (otherModels.length) {
const key = JSON.stringify(["channel", channel.id]);
groups.set(key, {
key,
label: channel.name || "未命名渠道",
icon: modelIcon(config, otherModels[0]),
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : "我的模型",
kind: "channel",
models: groupModelsByDisplayName(config, otherModels),
});
}
}
for (const group of groups.values()) {
if (group.kind !== "product") continue;
// 元数据按稳定身份取值,不随当前选中项或渠道排序变化;叶子顺序仍遵循后台排序。
const members = group.models.map((item) => item.models[0]).sort();
const named = members.find((value) => configuredModelDisplayName(config, value) !== modelOptionName(value));
group.label = configuredModelDisplayName(config, named || members[0]);
group.icon = members.map((value) => modelIcon(config, value)).find(Boolean) || "";
}
return Array.from(groups.values());
}

View File

@@ -1,7 +1,7 @@
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
import type { ModelRequestIntent } from "@/services/api/logical-models";
import type { LogicalModelQuote, ModelQuoteRequest, ModelRequestIntent } from "@/services/api/logical-models";
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
export type ModelPriceTier = NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number];
@@ -52,7 +52,7 @@ export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capabilit
return matched;
}
export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
export function priceTierSummaryLabel(tiers: ModelPriceTier[], capability?: ModelCapability) {
const unitPrices = (billingMode: ModelPriceTier["billingMode"]) =>
tiers
.filter((tier) => tier.billingMode === billingMode)
@@ -60,23 +60,41 @@ export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
.filter((value) => Number.isFinite(value) && value >= 0);
const fixedRequestValues = unitPrices("fixed_request");
const perSecondValues = unitPrices("per_second");
if (fixedRequestValues.length) return formatPriceRange(fixedRequestValues, "积分");
if (perSecondValues.length) return formatPriceRange(perSecondValues, "积分/秒");
return tiers.some((tier) => tier.billingMode === "token") ? "按量预估" : "未配置";
const tokenValues = tiers.filter((tier) => tier.billingMode === "token").map((tier) => tier.outputTokenPriceMicrocredits / 1_000_000).filter((value) => Number.isFinite(value) && value >= 0);
return [
fixedRequestValues.length ? formatPriceRange(fixedRequestValues, "积分") : "",
perSecondValues.length ? formatPriceRange(perSecondValues, "积分/秒") : "",
tokenValues.length ? `${capability === "video" ? "" : "输出 "}${formatPriceRange(tokenValues, capability === "video" ? "积分/百万视频 Token" : "积分/百万 Token")}` : "",
].filter(Boolean).join(" · ") || "未配置";
}
export function formatPriceRange(values: number[], suffix: string) {
const unique = Array.from(new Set(values)).sort((left, right) => left - right);
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 3 });
const format = (value: number) => value.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
return unique.length === 1 ? `${format(unique[0])} ${suffix}` : `${format(unique[0])}-${format(unique[unique.length - 1])} ${suffix}`;
}
export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): { logicalModelID: string; intent: ModelRequestIntent } | undefined {
export function modelQuoteDescription(quote: LogicalModelQuote) {
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const descriptions = [`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`];
const estimate = quote.videoTokenEstimate;
if (estimate) {
descriptions.push(`公式预估 ${estimate.formulaTokens.toLocaleString("zh-CN")} 视频 Token,平台额外预留 ${estimate.reservationMarginPercent}%(共 ${estimate.reservedTokens.toLocaleString("zh-CN")} Token)`);
if (estimate.referenceDurationEstimated) descriptions.push("未知的参考视频时长按 15 秒估算");
if (estimate.dimensionsEstimated) descriptions.push("实际画幅尚未确定,尺寸按当前参数估算");
}
if (quote.billingMode === "token") descriptions.push(estimate
? "预估金额并非费用上限,成功任务优先按有效的实际用量结算;未返回用量时按火山引擎公式结算,额外预留不计入公式结算,可能补扣或退回差额"
: "预估金额并非费用上限,最终以成功任务返回的实际用量结算,可能补扣或退回差额");
return descriptions.join(";");
}
export function modelQuoteRequest(config: AiConfig, value: string, capability?: ModelCapability, requirements?: ModelRequirements): ModelQuoteRequest | undefined {
if (!capability || !value) return undefined;
const channel = resolveModelChannel(config, value);
if (channel.scope !== "system") return undefined;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
if (!cost?.logicalModelId) return undefined;
if (!cost || (!cost.logicalModelId && capability !== "video")) return undefined;
const input = requirements?.input;
const intent: ModelRequestIntent = {
capability,
@@ -93,7 +111,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?:
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
},
};
return { logicalModelID: cost.logicalModelId, intent };
return cost.logicalModelId ? { logicalModelID: cost.logicalModelId, intent } : { channelId: channel.id, modelKey: modelOptionName(value), intent };
}
function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) {
@@ -109,11 +127,14 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
const imageCount = (input.imageCount || 0) + (input.characterCount || 0);
requested.operation = input.videoCount > 0 ? "video_to_video" : imageCount > 0 ? "image_to_video" : resolveVideoOperation(input, requirements?.videoOperation);
if (imageCount > 0) requested.imageCount = String(imageCount);
}
const resolution = normalizeTierResolution(config.vquality);
} else if (requirements?.videoOperation) requested.operation = requirements.videoOperation;
const options: Record<string, unknown> = { ...modelRequestOptions(config, "video"), ...requirements?.options, ...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}) };
const resolution = normalizeTierResolution(String(options.vquality ?? ""));
if (resolution !== "*") requested.vquality = resolution;
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
const seconds = Math.max(0, Math.floor(Number(options.videoSeconds) || 0));
if (seconds > 0) requested.videoSeconds = String(seconds);
if (options.videoGenerateAudio === true || options.videoGenerateAudio === "true") requested.videoGenerateAudio = "true";
if (options.videoGenerateAudio === false || options.videoGenerateAudio === "false") requested.videoGenerateAudio = "false";
}
if (capability === "image") {
requested.operation = imagePriceOperation(requirements);

View File

@@ -17,8 +17,8 @@ export function isVolcengineArkVideoProtocol(protocol?: string) {
return protocol === "volcengine-ark-video" || protocol === "volcengine-ark-agent-plan-video";
}
export function modelProtocolSupportsTokenBilling(capability?: string, protocol?: string) {
return capability === "text" || (capability === "video" && isVolcengineArkVideoProtocol(protocol));
export function modelProtocolSupportsTokenBilling(capability?: string, _protocol?: string) {
return capability === "text" || capability === "video";
}
export function protocolForModelCatalog(_endpointTypes: string[] = []): ModelProtocol | undefined {