feat(*): 模型计费与渠道目录 - 支持视频 Token 报价结算和稳定模型展示
This commit is contained in:
1 parent
90add8c466
commit
1a79005d25
57 files changed
+2904
-341
No files matched your search
@@ -8,12 +8,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||
|
||||
// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
|
||||
// 优先使用品牌原色,缺少彩色版本时使用 Mono。不能使用 eager glob:目录有数百个模块,
|
||||
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
|
||||
const iconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
|
||||
const colorIconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Color.js", { import: "default" });
|
||||
const iconLoaders = Object.fromEntries(
|
||||
Object.entries(iconModules)
|
||||
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])
|
||||
[...Object.entries(iconModules), ...Object.entries(colorIconModules)]
|
||||
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/(?:Mono|Color)\.js$/)?.[1], loader])
|
||||
.filter((entry): entry is [string, () => Promise<LobeIconComponent>] => Boolean(entry[0] && entry[1])),
|
||||
) as Record<string, () => Promise<LobeIconComponent>>;
|
||||
const iconRegistry = new Map<string, LobeIconComponent>();
|
||||
|
||||
@@ -4,14 +4,15 @@ import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel } from "@/lib/model-picker-groups";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||||
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
@@ -58,26 +59,11 @@ export function ModelPicker({
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
|
||||
const optionGroups = useMemo(() => {
|
||||
const channelGroups = config.channels
|
||||
.map((channel) => ({
|
||||
key: channel.id,
|
||||
label: channel.name || "未命名渠道",
|
||||
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : channel.scope === "system" ? "平台服务" : "我的模型",
|
||||
models: groupModelsByDisplayName(
|
||||
config,
|
||||
options.filter((model) => resolveModelChannel(config, model).id === channel.id),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.models.length);
|
||||
// options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃,
|
||||
// 不再显示“其他模型 / 未指定渠道”这种不可用入口。
|
||||
return channelGroups;
|
||||
}, [config, options]);
|
||||
const optionGroups = useMemo(() => groupModelsForPicker(config, options), [config, options]);
|
||||
const storedCurrent = value?.trim() || "";
|
||||
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
|
||||
const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined;
|
||||
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
|
||||
const resolvedCurrent = isDirectSystemModel(config, storedCurrent) ? storedCurrent : resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
|
||||
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
|
||||
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
|
||||
const currentPrice = modelMenuPrice(config, current, capability, false, requirements);
|
||||
@@ -102,7 +88,7 @@ export function ModelPicker({
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setRouteQuote(undefined);
|
||||
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
|
||||
quoteModel(quoteRequest, controller.signal)
|
||||
.then((payload) => setRouteQuote(payload.quote))
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setRouteQuote(undefined);
|
||||
@@ -142,7 +128,7 @@ export function ModelPicker({
|
||||
};
|
||||
const focusMenuOption = (last = false) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
|
||||
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[data-model-picker-item]:not(:disabled)');
|
||||
const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0);
|
||||
target?.focus();
|
||||
});
|
||||
@@ -160,8 +146,14 @@ export function ModelPicker({
|
||||
triggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowLeft" && activeGroupKey !== null) {
|
||||
event.preventDefault();
|
||||
setActiveGroupKey(null);
|
||||
focusMenuOption();
|
||||
return;
|
||||
}
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||||
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
|
||||
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[data-model-picker-item]:not(:disabled)'));
|
||||
if (!buttons.length) return;
|
||||
event.preventDefault();
|
||||
const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
@@ -191,32 +183,32 @@ export function ModelPicker({
|
||||
>
|
||||
{optionGroups.length ? (
|
||||
activeGroupKey === null ? (
|
||||
<div className="canvas-model-picker-brands" aria-label="选择模型品牌">
|
||||
<div className="canvas-model-picker-brands" aria-label="选择产品模型">
|
||||
{optionGroups.map((group) => {
|
||||
const groupCurrent = group.models.find((item) => item.models.includes(current));
|
||||
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
|
||||
return <button key={group.key} type="button" className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
|
||||
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
|
||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
||||
return <button key={group.key} type="button" data-model-picker-item className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); focusMenuOption(); }}>
|
||||
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
|
||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
||||
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
) : <div className="canvas-model-picker-two-pane">
|
||||
<div className="canvas-model-picker-brand-rail" aria-label="模型品牌">
|
||||
<div className="canvas-model-picker-brand-rail" aria-label="产品模型">
|
||||
{optionGroups.map((group) => {
|
||||
const groupCurrent = group.models.find((item) => item.models.includes(current));
|
||||
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
|
||||
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
|
||||
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
|
||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
||||
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
|
||||
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
|
||||
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) => <section key={group.key} className="canvas-model-picker-group canvas-model-picker-model-pane min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-secondary-head">
|
||||
<button type="button" className="canvas-model-picker-back" onClick={() => setActiveGroupKey(null)} aria-label="返回品牌列表"><ChevronLeft /></button>
|
||||
<button type="button" className="canvas-model-picker-back" onClick={() => { setActiveGroupKey(null); focusMenuOption(); }} aria-label="返回模型列表"><ChevronLeft /></button>
|
||||
<span><strong>{group.label}</strong>{group.scope ? <small>{group.scope}</small> : null}</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1">
|
||||
@@ -229,6 +221,7 @@ export function ModelPicker({
|
||||
<button
|
||||
key={modelGroup.key}
|
||||
type="button"
|
||||
data-model-picker-item
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-disabled={Boolean(disabledReason)}
|
||||
@@ -252,6 +245,8 @@ export function ModelPicker({
|
||||
theme={theme}
|
||||
creationVariant
|
||||
showConfiguredModelName={showConfiguredModelName}
|
||||
label={group.kind === "product" ? modelGroup.label : undefined}
|
||||
requirements={requirements}
|
||||
showPrice={showOptionPrices && creditsEnabled}
|
||||
disabledReason={disabledReason}
|
||||
showDescription={selected || previewedModel === displayModel}
|
||||
@@ -323,6 +318,8 @@ function ModelLabel({
|
||||
theme,
|
||||
creationVariant,
|
||||
showConfiguredModelName,
|
||||
label,
|
||||
requirements,
|
||||
showPrice,
|
||||
disabledReason,
|
||||
showDescription,
|
||||
@@ -333,6 +330,8 @@ function ModelLabel({
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
creationVariant: boolean;
|
||||
showConfiguredModelName: boolean;
|
||||
label?: string;
|
||||
requirements?: ModelRequirements;
|
||||
showPrice: boolean;
|
||||
disabledReason?: string;
|
||||
showDescription: boolean;
|
||||
@@ -345,21 +344,21 @@ function ModelLabel({
|
||||
const capabilitySummary =
|
||||
disabledReason ||
|
||||
logicalCost?.description?.trim() ||
|
||||
(logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
|
||||
(isDirectSystemModel(config, model) ? "" : logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
|
||||
<ModelIcon config={config} model={model} />
|
||||
</span>
|
||||
<span className="min-w-44 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{label || pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
|
||||
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }} title={capabilitySummary}>
|
||||
{capabilitySummary}
|
||||
</span>
|
||||
</span>
|
||||
{showPrice ? (
|
||||
<span className="ml-auto shrink-0 pl-2">
|
||||
<ModelPrice price={modelMenuPrice(config, model, capability, true)} />
|
||||
<ModelPrice price={modelMenuPrice(config, model, capability, !requirements, requirements)} />
|
||||
</span>
|
||||
) : null}
|
||||
{!creationVariant && meta.time ? (
|
||||
@@ -431,7 +430,7 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
|
||||
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
|
||||
}
|
||||
|
||||
type ModelMenuPrice = { kind: "tiers"; label: string; compactLabel: string; title: string } | { kind: "estimate" } | { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
|
||||
type ModelMenuPrice = { kind: "tiers"; label: string; compactLabel: string; title: string } | { kind: "estimate"; label?: string; title?: string } | { kind: "fixed"; value: number; unit: "次" | "秒" };
|
||||
|
||||
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false, requirements?: ModelRequirements): ModelMenuPrice | null | undefined {
|
||||
if (!model) return undefined;
|
||||
@@ -442,32 +441,40 @@ function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapab
|
||||
const tiers = cost.logicalPriceTiers || [];
|
||||
if (!tiers.length) return null;
|
||||
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements);
|
||||
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
|
||||
if (!summary && !matched.length) return { kind: "tiers", label: "当前规格无报价", compactLabel: "当前规格无报价", title: "请调整参数,或选择支持当前规格的渠道" };
|
||||
return channelTierPriceSummary(matched.length ? matched : tiers, tiers, capability);
|
||||
}
|
||||
if (cost.billingMode === "token") {
|
||||
const rate = cost.outputTokenPriceMicrocredits;
|
||||
return capability === "video" && typeof rate === "number" && Number.isFinite(rate) && rate >= 0
|
||||
? { kind: "estimate", label: formatPriceRange([rate / 1_000_000], "积分/百万视频 Token"), title: "按视频 Token 单价预估,优先按有效上游用量结算;未返回用量时按视频公式结算" }
|
||||
: { kind: "estimate" };
|
||||
}
|
||||
if (cost.billingMode === "token") return { kind: "estimate" };
|
||||
return { kind: "fixed", value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
|
||||
}
|
||||
|
||||
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
|
||||
return showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
|
||||
const name = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
|
||||
return isDirectSystemModel(config, model) ? `${name} · ${modelChannelLabel(config, model)}` : name;
|
||||
}
|
||||
|
||||
function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) {
|
||||
const displayName = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
|
||||
const channel = resolveModelChannel(config, model);
|
||||
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
|
||||
return channel.scope === "system" ? pickerModelDisplayName(config, model, showConfiguredModelName) : `${displayName}(${channel.name})`;
|
||||
}
|
||||
|
||||
function channelTierPriceSummary(
|
||||
visibleTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
|
||||
capability?: ModelCapability,
|
||||
): Extract<ModelMenuPrice, { kind: "tiers" }> {
|
||||
const label = priceTierSummaryLabel(visibleTiers);
|
||||
const label = priceTierSummaryLabel(visibleTiers, capability);
|
||||
return {
|
||||
kind: "tiers",
|
||||
label,
|
||||
compactLabel: label,
|
||||
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${tierPriceLabel(tier)}`).join(";")}`,
|
||||
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${priceTierSummaryLabel([tier], capability)}`).join(";")}${allTiers.some((tier) => tier.billingMode === "token") ? (capability === "video" ? ";优先按有效上游用量结算,未返回用量时按视频公式结算" : ";Token 费用为预估,最终按成功任务的实际用量结算") : ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -491,21 +498,17 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
|
||||
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
|
||||
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
|
||||
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
|
||||
selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "",
|
||||
].filter(Boolean);
|
||||
return details.length ? details.join(" / ") : "默认规格";
|
||||
}
|
||||
|
||||
function tierPriceLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
|
||||
if (tier.billingMode === "token") return "按量预估";
|
||||
return `${formatPriceRange([tier.unitPriceMicrocredits / 1_000_000], tier.billingMode === "per_second" ? "积分/秒" : "积分")}`;
|
||||
}
|
||||
|
||||
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
|
||||
if (quote) {
|
||||
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 });
|
||||
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`}>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={modelQuoteDescription(quote)}>
|
||||
<Coins className="size-3" />
|
||||
{compact ? label : `${label} 积分`}
|
||||
</span>
|
||||
@@ -522,7 +525,7 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
|
||||
);
|
||||
}
|
||||
if (price.kind === "estimate") {
|
||||
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300">按量预估</span>;
|
||||
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300" title={price.title}>{price.label || "按量预估"}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
|
||||
@@ -554,5 +557,5 @@ function modelMenuMeta(model: string, capability?: ModelCapability): { descripti
|
||||
}
|
||||
|
||||
export function ModelIcon({ config, model, icon }: { config?: AiConfig; model?: string; icon?: string }) {
|
||||
return <ModelLogo icon={icon || (config && model ? modelIcon(config, model) : "")} size={14} className="opacity-80" />;
|
||||
return <ModelLogo icon={icon || (config && model ? modelIcon(config, model) : "")} size={14} />;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ChannelModelFormValues = {
|
||||
modelKey: string;
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
channelLabel?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
capability: ModelCapabilityChoice;
|
||||
protocol?: string;
|
||||
@@ -30,6 +32,8 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
|
||||
modelKey: item?.modelKey || "",
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: item?.displayName || "",
|
||||
channelLabel: item?.channelLabel || "",
|
||||
description: item?.description || "",
|
||||
icon: item?.icon || "",
|
||||
capability,
|
||||
protocol,
|
||||
@@ -54,6 +58,7 @@ export function changeChannelModelCapability(values: ChannelModelFormValues, pro
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
videoGenerateAudio: "*",
|
||||
imageCount: 0,
|
||||
})),
|
||||
};
|
||||
@@ -81,15 +86,15 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
|
||||
};
|
||||
if (!["fixed_request", "per_second", "token"].includes(tier.billingMode)) fail("请选择计费方式");
|
||||
if (tier.billingMode === "per_second" && capability !== "video") fail("按秒计费仅支持视频,请重新选择计费方式并核对价格");
|
||||
if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前协议不支持 Token 计费,请重新选择计费方式并核对价格");
|
||||
if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前模型能力不支持 Token 计费,请重新选择计费方式并核对价格");
|
||||
if (tier.matchMode === "advanced") {
|
||||
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
|
||||
const specific = (value: string | undefined) => Boolean(value && value !== "*");
|
||||
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0))))
|
||||
if (capability === "video" && tier.videoGenerateAudio && !["*", "true", "false"].includes(tier.videoGenerateAudio)) fail("请选择任意音频、有声或无声");
|
||||
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0 || specific(tier.videoGenerateAudio)))))
|
||||
fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
|
||||
}
|
||||
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
|
||||
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");
|
||||
if (tier.billingMode === "token" && capability === "video" && tier.outputTokenPrice < 0.000001) fail("视频 Token 价格必须至少为 0.000001");
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type Mode
|
||||
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
|
||||
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form";
|
||||
import { PriceTierFields } from "./channel-model-price-tier-fields";
|
||||
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
|
||||
|
||||
@@ -106,22 +106,12 @@ export function ChannelModelEditor({
|
||||
modelKey: values.modelKey.trim(),
|
||||
providerModelKey: upstreamModel,
|
||||
displayName: values.displayName?.trim() || values.modelKey.trim(),
|
||||
channelLabel: values.channelLabel?.trim() || "",
|
||||
description: values.description?.trim() || "",
|
||||
icon: values.icon?.trim() || "",
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
selector: skuSelectorFromForm(values.capability, tier),
|
||||
resolution: priceTierResolutionFromForm(values.capability, tier),
|
||||
videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier),
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
billingMode: tier.billingMode,
|
||||
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
|
||||
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: tier.priceConfigured !== false,
|
||||
enabled: tier.enabled !== false,
|
||||
})),
|
||||
priceTiers: values.priceTiers.map((tier) => priceTierPayloadFromForm(values.capability, tier, upstreamModel)),
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig,
|
||||
};
|
||||
@@ -244,7 +234,7 @@ export function ChannelModelEditor({
|
||||
<section className="admin-model-editor-section">
|
||||
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
|
||||
<div className="admin-model-editor-section-content admin-model-identity-grid admin-model-identity-grid-with-icon">
|
||||
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, whitespace: true, message: "请输入产品模型标识" }]}>
|
||||
<Form.Item name="modelKey" label="产品模型标识" tooltip="不同渠道使用相同标识时,创作端归为同一个产品模型。不同版本(例如 Fast)应使用不同标识;请勿为分组随意修改已有标识。" rules={[{ required: true, whitespace: true, message: "请输入产品模型标识" }]}>
|
||||
<Input
|
||||
prefix={
|
||||
<span className="grid size-6 place-items-center">
|
||||
@@ -257,9 +247,12 @@ export function ChannelModelEditor({
|
||||
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
|
||||
<Input placeholder="留空则使用产品模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label="后台显示名称" tooltip="仅用于后台识别,不改变调用 ID。">
|
||||
<Form.Item name="displayName" label="模型展示名" tooltip="创作端一级菜单名称。同一产品模型标识请使用相同名称和 Logo,不改变调用 ID。">
|
||||
<Input placeholder="不填则使用模型标识" />
|
||||
</Form.Item>
|
||||
<Form.Item name="channelLabel" label="渠道展示名" tooltip="创作端二级菜单名称,仅作用于此渠道中的当前模型。留空使用渠道公开名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
|
||||
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
|
||||
</Form.Item>
|
||||
<Form.Item name="icon" label="模型 Logo">
|
||||
<ModelIconPicker />
|
||||
</Form.Item>
|
||||
@@ -272,6 +265,9 @@ export function ChannelModelEditor({
|
||||
description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。"
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item name="description" label="模型描述" extra="在创作端二级渠道选项悬浮或聚焦时显示,可说明适用场景、渠道差异和注意事项。" rules={[{ max: 500, message: "模型描述不能超过 500 字" }]}>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写此渠道模型的使用说明" />
|
||||
</Form.Item>
|
||||
</section>
|
||||
<section className="admin-model-editor-section">
|
||||
<SectionHeading title="能力与协议" description="先选任务类型,再选择对应的调用协议;更换后请核对参数与价格。" />
|
||||
|
||||
@@ -183,6 +183,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
|
||||
<div className="admin-monospace truncate text-xs text-foreground/45">{item.modelKey}</div>
|
||||
{item.channelLabel ? <div className="truncate text-xs text-foreground/60">渠道展示名:{item.channelLabel}</div> : null}
|
||||
{item.providerModelKey && item.providerModelKey !== item.modelKey ? <div className="admin-monospace truncate text-xs text-foreground/35">上游:{item.providerModelKey}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,7 +225,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName}`.toLowerCase().includes(query)) return false;
|
||||
if (query && !`${item.modelKey} ${item.providerModelKey} ${item.displayName} ${item.channelLabel || ""}`.toLowerCase().includes(query)) return false;
|
||||
if (capability !== "all" && item.capability !== capability) return false;
|
||||
if (status === "enabled" && !item.enabled) return false;
|
||||
if (status === "disabled" && item.enabled) return false;
|
||||
@@ -473,14 +474,14 @@ function billingSummary(item: ChannelModel) {
|
||||
return (
|
||||
<div className="space-y-1 text-xs leading-5">
|
||||
{tiers.slice(0, 3).map((tier) => (
|
||||
<div key={tier.id}>{priceTierLabel(tier)}</div>
|
||||
<div key={tier.id}>{priceTierLabel(tier, item.capability)}</div>
|
||||
))}
|
||||
{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function priceTierLabel(tier: ChannelModelPriceTier) {
|
||||
function priceTierLabel(tier: ChannelModelPriceTier, capability: ChannelModel["capability"]) {
|
||||
const selector = tier.selector || {};
|
||||
const specParts = [
|
||||
selector.operation && selector.operation !== "*" ? operationLabel(selector.operation) : "任意生成方式",
|
||||
@@ -489,9 +490,10 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
|
||||
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
|
||||
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
|
||||
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
|
||||
selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "",
|
||||
].filter(Boolean);
|
||||
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
|
||||
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
|
||||
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} 积分 / 百万${capability === "video" ? "视频 " : " "}Token`;
|
||||
return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export function PriceTierFields({
|
||||
const video = capabilityConfig?.video;
|
||||
const resolutionOptions = video?.resolutions || [];
|
||||
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
|
||||
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
|
||||
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
|
||||
const isVideo = capability === "video";
|
||||
const isImage = capability === "image";
|
||||
return (
|
||||
@@ -91,14 +91,14 @@ export function PriceTierFields({
|
||||
options={[
|
||||
{ label: "按次", value: "fixed_request" },
|
||||
{ label: "按秒", value: "per_second", disabled: !isVideo },
|
||||
{ label: "Token", value: "token", disabled: !tokenEnabled },
|
||||
{ label: isVideo ? "视频 Token" : "Token", value: "token", disabled: !tokenEnabled },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
isVideo ? (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="积分 / 百万视频 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="admin-price-tier-token-grid">
|
||||
@@ -119,6 +119,11 @@ export function PriceTierFields({
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
{isVideo && billingMode === "token" ? (
|
||||
<p className="mb-0 mt-2 text-xs text-foreground/60">
|
||||
所有视频模型均支持 Token 计费,统一按火山引擎模式计算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024。优先按成功任务返回的有效用量结算,未返回用量时按公式结算。平台额外预留 10%,不计入公式结算;最终可能补扣或退回差额。0 表示免费;有声、无声或参考视频可分别配置规格价格。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{matchMode !== "default" && (
|
||||
<div className="admin-price-tier-match-grid">
|
||||
@@ -135,6 +140,11 @@ export function PriceTierFields({
|
||||
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "videoGenerateAudio"]} label="生成音频" rules={[{ required: true, message: "请选择音频条件" }]}>
|
||||
<Select options={[{ label: "任意音频", value: "*" }, { label: "有声", value: "true" }, { label: "无声", value: "false" }]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
|
||||
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PriceTierFormValues = {
|
||||
size: string;
|
||||
resolution: string;
|
||||
videoSeconds: number;
|
||||
videoGenerateAudio: string;
|
||||
imageCount: number;
|
||||
providerModelKey?: string;
|
||||
billingMode: ChannelModel["billingMode"];
|
||||
@@ -34,6 +35,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
videoGenerateAudio: "*",
|
||||
imageCount: 0,
|
||||
providerModelKey: "",
|
||||
billingMode: "fixed_request",
|
||||
@@ -56,6 +58,7 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
|
||||
size: selector.size || "*",
|
||||
resolution: tier.resolution || "*",
|
||||
videoSeconds: tier.videoSeconds || 0,
|
||||
videoGenerateAudio: selector.videoGenerateAudio || "*",
|
||||
imageCount: Number(selector.imageCount || 0),
|
||||
providerModelKey: tier.providerModelKey || "",
|
||||
billingMode: tier.billingMode,
|
||||
@@ -91,6 +94,7 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
|
||||
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
|
||||
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
|
||||
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
|
||||
if (tier.videoGenerateAudio && tier.videoGenerateAudio !== "*") selector.videoGenerateAudio = tier.videoGenerateAudio;
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
|
||||
@@ -106,3 +110,20 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
|
||||
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
|
||||
return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0;
|
||||
}
|
||||
|
||||
export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) {
|
||||
const videoTokens = capability === "video" && tier.billingMode === "token";
|
||||
return {
|
||||
selector: skuSelectorFromForm(capability, tier),
|
||||
resolution: priceTierResolutionFromForm(capability, tier),
|
||||
videoSeconds: priceTierVideoSecondsFromForm(capability, tier),
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
billingMode: tier.billingMode,
|
||||
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
|
||||
inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: tier.priceConfigured !== false,
|
||||
enabled: tier.enabled !== false,
|
||||
};
|
||||
}
|
||||
@@ -132,6 +132,25 @@ export type LogicalModelQuote = {
|
||||
quantity: number;
|
||||
amountMicrocredits: number;
|
||||
estimated: boolean;
|
||||
videoTokenEstimate?: {
|
||||
formulaTokens: number;
|
||||
reservedTokens: number;
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
framesPerSecond: number;
|
||||
outputSeconds: number;
|
||||
referenceSeconds: number;
|
||||
referenceDurationEstimated: boolean;
|
||||
dimensionsEstimated: boolean;
|
||||
reservationMarginPercent: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ModelQuoteRequest = {
|
||||
logicalModelID?: string;
|
||||
channelId?: string;
|
||||
modelKey?: string;
|
||||
intent: ModelRequestIntent;
|
||||
};
|
||||
|
||||
export type ModelCatalogSource = "frontend" | "system";
|
||||
@@ -148,6 +167,8 @@ export type PublicChannelModel = {
|
||||
id: string;
|
||||
modelKey: string;
|
||||
displayName: string;
|
||||
channelLabel?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
icon: string;
|
||||
capability: string;
|
||||
@@ -187,6 +208,12 @@ export function quoteLogicalModel(id: string, intent: ModelRequestIntent, signal
|
||||
return http.post<{ quote: LogicalModelQuote }>(`/models/${encodeURIComponent(id)}/quote`, intent, { signal });
|
||||
}
|
||||
|
||||
export function quoteModel(request: ModelQuoteRequest, signal?: AbortSignal) {
|
||||
if (request.logicalModelID) return quoteLogicalModel(request.logicalModelID, request.intent, signal);
|
||||
if (!request.channelId || !request.modelKey) return Promise.reject(new Error("请选择需要报价的系统模型"));
|
||||
return http.post<{ quote: LogicalModelQuote }>("/model-catalog/quote", { channelId: request.channelId, modelKey: request.modelKey, intent: request.intent }, { signal });
|
||||
}
|
||||
|
||||
export function listAdminLogicalModels() {
|
||||
return http.get<{ models?: AdminLogicalModel[] }>("/admin/logical-models").then((result) => ({
|
||||
models: Array.isArray(result?.models) ? result.models.filter(Boolean).map(normalizeAdminLogicalModel) : [],
|
||||
|
||||
@@ -52,6 +52,8 @@ export type ChannelModel = {
|
||||
modelKey: string;
|
||||
providerModelKey: string;
|
||||
displayName: string;
|
||||
channelLabel?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
icon: string;
|
||||
capability: "text" | "image" | "video" | "audio" | "";
|
||||
@@ -96,6 +98,8 @@ export type ChannelModelMutation = {
|
||||
modelKey: string;
|
||||
providerModelKey?: string;
|
||||
displayName?: string;
|
||||
channelLabel?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
capability: ChannelModel["capability"];
|
||||
protocol?: ChannelModel["protocol"];
|
||||
@@ -214,6 +218,8 @@ export type BillingOrder = {
|
||||
outputTokens: number;
|
||||
cachedTokens: number;
|
||||
usageAvailable: boolean;
|
||||
videoFormulaTokens?: number;
|
||||
usageSource?: "provider" | "video_formula";
|
||||
status: "reserved" | "running" | "settled" | "refunded" | "uncertain";
|
||||
providerRequestId?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -28,11 +28,11 @@ describe("channel model editor drafts", () => {
|
||||
});
|
||||
test("capability changes clear old selectors but preserve money and billing units", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, imageCount: 2 };
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, videoGenerateAudio: "false", imageCount: 2 };
|
||||
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "video", providerModelKey: "gpt-image-2", priceTiers: [tier] }, protocols);
|
||||
expect(next.protocol).toBe("image");
|
||||
expect(next.capabilityConfig).toEqual(defaultModelCapabilityConfig("image", "gpt-image-2"));
|
||||
expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, imageCount: 0 });
|
||||
expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, videoGenerateAudio: "*", imageCount: 0 });
|
||||
expect(tier.operation).toBe("text_to_video");
|
||||
expect(() => validateChannelModelPrices(next)).toThrow("按秒");
|
||||
});
|
||||
@@ -63,16 +63,32 @@ describe("pricing write validation", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值");
|
||||
}
|
||||
});
|
||||
test("a new protocol cannot silently convert Token pricing to per request", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
|
||||
test("saves video Token prices with newapi-channel-2 and other video protocols without changing billing units", () => {
|
||||
for (const protocol of ["newapi-channel-2", "volcengine-jimeng-video", "video"]) {
|
||||
const priceTiers = [{ ...defaultPriceTier(), billingMode: "token" as const, outputTokenPrice: 8 }];
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol, priceTiers })).not.toThrow();
|
||||
expect(priceTiers[0].billingMode).toBe("token");
|
||||
expect(priceTiers[0].outputTokenPrice).toBe(8);
|
||||
}
|
||||
});
|
||||
test("validates all text Token prices and the video Token minimum", () => {
|
||||
test("rejects Token pricing after switching to image or audio", () => {
|
||||
for (const capability of ["image", "audio"] as const) {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
|
||||
}
|
||||
});
|
||||
test("validates all text Token prices and accepts free video Token pricing", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", inputTokenPrice: NaN }] })).toThrow();
|
||||
const video = { ...draft, capability: "video" as const, protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token" as const }] };
|
||||
expect(() => validateChannelModelPrices(video)).toThrow("至少为");
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
video.priceTiers[0].outputTokenPrice = 0.000001;
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
});
|
||||
test("accepts audio-only video price conditions and rejects invalid audio selectors", () => {
|
||||
for (const videoGenerateAudio of ["true", "false"]) {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", videoGenerateAudio }] })).not.toThrow();
|
||||
}
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声");
|
||||
});
|
||||
});
|
||||
|
||||
test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { defaultPriceTier, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import { defaultPriceTier, priceTierPayloadFromForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import type { ChannelModelPriceTier } from "../src/services/api/wallet";
|
||||
|
||||
describe("channel model price tier defaults", () => {
|
||||
@@ -20,7 +20,7 @@ describe("channel model price tier defaults", () => {
|
||||
});
|
||||
|
||||
test("drops stale video selectors after switching back to the default price", () => {
|
||||
const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, imageCount: 2 };
|
||||
const tier = { ...defaultPriceTier("advanced"), resolution: "1080p", videoSeconds: 10, videoGenerateAudio: "false", imageCount: 2 };
|
||||
const defaultTier = { ...tier, matchMode: "default" as const };
|
||||
|
||||
expect(skuSelectorFromForm("video", defaultTier)).toEqual({});
|
||||
@@ -51,5 +51,22 @@ describe("channel model price tier defaults", () => {
|
||||
|
||||
expect(priceTierToForm(base).matchMode).toBe("default");
|
||||
expect(priceTierToForm({ ...base, selector: { quality: "2k" } }).matchMode).toBe("advanced");
|
||||
expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "advanced", videoGenerateAudio: "false" });
|
||||
});
|
||||
|
||||
test("preserves explicit silent and audible selectors but omits the wildcard", () => {
|
||||
for (const videoGenerateAudio of ["true", "false"]) {
|
||||
const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio };
|
||||
expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio });
|
||||
expect(skuSelectorFromForm("image", tier)).toEqual({});
|
||||
}
|
||||
expect(skuSelectorFromForm("video", defaultPriceTier("advanced"))).toEqual({});
|
||||
});
|
||||
|
||||
test("video Token writes discard hidden text prices without changing the configured video rate", () => {
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "token" as const, videoGenerateAudio: "false", inputTokenPrice: 10, outputTokenPrice: 0.25, cachedTokenPrice: 5 };
|
||||
expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: { videoGenerateAudio: "false" }, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 });
|
||||
expect(priceTierPayloadFromForm("text", tier, "text-model")).toMatchObject({ inputTokenPriceMicrocredits: 10_000_000, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 5_000_000 });
|
||||
expect(priceTierPayloadFromForm("video", { ...tier, outputTokenPrice: 0 }, "seedance").outputTokenPriceMicrocredits).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { groupModelsForPicker, modelChannelLabel } from "../src/lib/model-picker-groups";
|
||||
import { modelCompatibilityError, resolveCompatibleModel } from "../src/lib/model-selection";
|
||||
import { modelQuoteRequest, priceTiersForCurrentSelection, priceTierSummaryLabel } from "../src/lib/model-pricing";
|
||||
import { systemChannelModelChannels } from "../src/lib/user-session";
|
||||
import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models";
|
||||
import { defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, selectableModelsByCapability } from "../src/stores/use-config-store";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
|
||||
function model(label = "", price = 300000): PublicChannelModel {
|
||||
return {
|
||||
id: label || "test-model", modelKey: "seedance-2.0", displayName: "Seedance 2.0", channelLabel: label,
|
||||
description: label ? `${label}的使用说明` : "",
|
||||
icon: "ByteDance", capability: "video", protocol: "seedance", available: true,
|
||||
pricingMode: "provider", priceLabel: "",
|
||||
capabilityConfig: defaultModelCapabilityConfig("seedance", "seedance-2.0"),
|
||||
priceTiers: [{ id: "tier", selector: {}, resolution: "*", videoSeconds: 0, billingMode: "per_second", unitPriceMicrocredits: price, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0, cachedTokenPriceMicrocredits: 0 }],
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const channels: PublicChannelCatalog[] = [
|
||||
{ id: "a", name: "正常渠道", displayName: "正常渠道", models: [model()] },
|
||||
{ id: "b", name: "渠道 B", displayName: "渠道 B", models: [model("优惠渠道-993")] },
|
||||
{ id: "c", name: "渠道 C", displayName: "渠道 C", models: [model("特惠渠道-730", 200000)] },
|
||||
];
|
||||
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config;
|
||||
}
|
||||
|
||||
test("one product exposes every channel and preserves prices through catalog normalization", () => {
|
||||
const config = fixture();
|
||||
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
|
||||
expect(groups[0]).toMatchObject({ label: "Seedance 2.0", icon: "ByteDance", kind: "product" });
|
||||
expect(groups[0].models.map((item) => [item.label, item.models])).toEqual([
|
||||
["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]],
|
||||
]);
|
||||
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
|
||||
});
|
||||
|
||||
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
|
||||
const config = fixture();
|
||||
const value = "b::seedance-2.0";
|
||||
expect(resolveCompatibleModel(config, value, { capability: "video" })).toBe(value);
|
||||
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
||||
expect(modelQuoteRequest(config, value, "video")).toMatchObject({ channelId: "b", modelKey: "seedance-2.0" });
|
||||
config.channels[1].modelCosts![0].channelLabel = "新展示名";
|
||||
expect(modelChannelLabel(config, value)).toBe("新展示名");
|
||||
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
|
||||
});
|
||||
|
||||
test("product keys and capabilities stay distinct despite equal display names", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].models = ["seedance-2.0-fast"];
|
||||
config.channels[1].modelCosts![0].model = "seedance-2.0-fast";
|
||||
config.channels[2].modelCosts![0].capability = "image";
|
||||
const groups = groupModelsForPicker(config, ["a::seedance-2.0", "b::seedance-2.0-fast", "c::seedance-2.0"]);
|
||||
expect(groups).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("group metadata is stable across channel reorder and current selection", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].modelCosts![0].displayName = "不同展示名";
|
||||
config.channels[1].modelCosts![0].icon = "Jimeng";
|
||||
const options = selectableModelsByCapability(config, "video");
|
||||
const first = groupModelsForPicker(config, options)[0];
|
||||
const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options)[0];
|
||||
expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]);
|
||||
expect(reordered.models[0].models).toEqual(["c::seedance-2.0"]);
|
||||
});
|
||||
|
||||
test("each channel keeps its own capability restrictions", () => {
|
||||
const config = fixture();
|
||||
config.channels[0].modelCosts![0].capabilityConfig!.video!.references.maxImages = 0;
|
||||
config.channels[1].modelCosts![0].capabilityConfig!.video!.references.maxImages = 2;
|
||||
config.channels[1].modelCosts![0].capabilityConfig!.video!.operations = ["image_to_video"];
|
||||
const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } };
|
||||
expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe("");
|
||||
expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe("");
|
||||
expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("managed logical models and personal channels never become system product routes", () => {
|
||||
const config = fixture();
|
||||
config.channels[1].id = "managed";
|
||||
config.channels[1].modelCosts![0].logicalModelId = "logical-test";
|
||||
config.channels[2].scope = "user";
|
||||
const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]);
|
||||
expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]);
|
||||
expect(groups[0].models).toHaveLength(1);
|
||||
expect(groupModelsForPicker(config, [])).toEqual([]);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { buildGenerationConfig } from "../src/lib/canvas/canvas-project-generati
|
||||
import { modelRequestOptions } from "../src/lib/model-selection";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) {
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; outputTokenPriceMicrocredits?: number }> }) {
|
||||
const capability = input.capability || "video";
|
||||
const model = capability === "video" ? "agnes-video-2.5" : "image-model";
|
||||
const channel = createModelChannel({
|
||||
@@ -32,7 +32,7 @@ function systemConfig(input: { capability?: "image" | "video"; logicalModelId?:
|
||||
resolution: tier.selector.vquality || "*",
|
||||
videoSeconds: Number(tier.selector.videoSeconds || 0),
|
||||
inputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: 0,
|
||||
outputTokenPriceMicrocredits: tier.outputTokenPriceMicrocredits ?? 0,
|
||||
cachedTokenPriceMicrocredits: 0,
|
||||
})),
|
||||
},
|
||||
@@ -235,4 +235,38 @@ describe("model request pricing", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("matches video audio, resolution and duration from actual request overrides", () => {
|
||||
const config = systemConfig({ tiers: [
|
||||
{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1_000_000 },
|
||||
{ selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 2_000_000 },
|
||||
{ selector: { vquality: "1080p", videoSeconds: "10", videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
for (const videoGenerateAudio of [false, "false", true, "true"]) {
|
||||
const requirements = { ...textVideoRequirements, videoSeconds: "10", options: { vquality: "1080P", videoSeconds: 5, videoGenerateAudio } };
|
||||
const matched = priceTiersForCurrentSelection(tiers, "video", config, requirements);
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0]?.outputTokenPriceMicrocredits).toBe(videoGenerateAudio === true || videoGenerateAudio === "true" ? 3_000_000 : 2_000_000);
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "agnes-video-2.5", capability: "video", config, requirements })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the configured video Token rate including free and fractional rates", () => {
|
||||
const config = systemConfig({ tiers: [
|
||||
{ selector: { videoGenerateAudio: "false" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 0 },
|
||||
{ selector: { videoGenerateAudio: "true" }, billingMode: "token", unitPriceMicrocredits: 0, outputTokenPriceMicrocredits: 1 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
expect(priceTierSummaryLabel(tiers, "video")).toBe("0-0.000001 积分/百万视频 Token");
|
||||
expect(priceTierSummaryLabel(tiers.slice(0, 1), "video")).toBe("0 积分/百万视频 Token");
|
||||
});
|
||||
|
||||
test("builds a direct system-video quote without inventing a logical model ID", () => {
|
||||
const config = systemConfig({ tiers: [{ selector: {}, billingMode: "token", unitPriceMicrocredits: 0 }] });
|
||||
const requirements = { ...textVideoRequirements, options: { videoGenerateAudio: false, vquality: "1080p" } };
|
||||
expect(modelQuoteRequest(config, config.model, "video", requirements)).toMatchObject({ channelId: "system-channel", modelKey: "agnes-video-2.5", intent: { options: { videoGenerateAudio: false, vquality: "1080p" } } });
|
||||
expect(modelQuoteRequest(config, config.model, "video", requirements)?.logicalModelID).toBeUndefined();
|
||||
expect(modelQuoteRequest(config, config.model, "image", requirements)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,21 @@ import { describe, expect, test } from "bun:test";
|
||||
import { modelProtocolSupportsTokenBilling } from "../src/lib/model-protocols";
|
||||
|
||||
describe("model protocol Token billing", () => {
|
||||
test("supports text models and Volcengine Ark video only", () => {
|
||||
test("supports text and every video protocol", () => {
|
||||
expect(modelProtocolSupportsTokenBilling("text", "chat-completion")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-ark-agent-plan-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "volcengine-jimeng-video")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi-channel-2")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "newapi")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video", "custom-video-protocol")).toBe(true);
|
||||
expect(modelProtocolSupportsTokenBilling("video")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not enable Token billing for image, audio or unselected capabilities", () => {
|
||||
expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-image")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("image", "volcengine-ark-agent-plan-image")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling("audio", "speech")).toBe(false);
|
||||
expect(modelProtocolSupportsTokenBilling(undefined, "volcengine-ark-video")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { modelQuoteDescription } from "../src/lib/model-pricing";
|
||||
import { quoteModel, type LogicalModelQuote, type ModelRequestIntent } from "../src/services/api/logical-models";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
|
||||
const intent: ModelRequestIntent = { capability: "video", operation: "text_to_video", inputs: { image: 0, video: 0, audio: 0 }, options: { videoSeconds: 5, videoGenerateAudio: false } };
|
||||
const quote: LogicalModelQuote = { logicalModelId: "", billingMode: "token", quantity: 1, amountMicrocredits: 500_000, estimated: true };
|
||||
|
||||
describe("model quote API targets", () => {
|
||||
test("direct system videos send the actual options and cancellation signal to the catalog quote", async () => {
|
||||
const original = apiClient.request;
|
||||
const calls: unknown[] = [];
|
||||
apiClient.request = (async (config: unknown) => {
|
||||
calls.push(config);
|
||||
return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
expect(await quoteModel({ channelId: "channel-1", modelKey: "seedance", intent }, controller.signal)).toEqual({ quote });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({ method: "post", url: "/model-catalog/quote", data: { channelId: "channel-1", modelKey: "seedance", intent }, signal: controller.signal });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("logical models retain their dedicated quote route", async () => {
|
||||
const original = apiClient.request;
|
||||
const calls: unknown[] = [];
|
||||
apiClient.request = (async (config: unknown) => {
|
||||
calls.push(config);
|
||||
return { data: { code: 0, data: { quote }, msg: "ok" }, status: 200, headers: {} };
|
||||
}) as typeof apiClient.request;
|
||||
try {
|
||||
await quoteModel({ logicalModelID: "logical/video", intent });
|
||||
expect(calls[0]).toMatchObject({ method: "post", url: "/models/logical%2Fvideo/quote", data: intent });
|
||||
} finally {
|
||||
apiClient.request = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("missing quote targets fail before issuing a request", async () => {
|
||||
await expect(quoteModel({ intent })).rejects.toThrow("请选择");
|
||||
});
|
||||
});
|
||||
|
||||
test("video estimates disclose platform reserves, unknown references and final settlement", () => {
|
||||
const description = modelQuoteDescription({
|
||||
...quote,
|
||||
videoTokenEstimate: { formulaTokens: 100_000, reservedTokens: 110_000, outputWidth: 1280, outputHeight: 720, framesPerSecond: 24, outputSeconds: 5, referenceSeconds: 15, referenceDurationEstimated: true, dimensionsEstimated: true, reservationMarginPercent: 10 },
|
||||
});
|
||||
expect(description).toContain("预计消耗 0.5 积分");
|
||||
expect(description).toContain("公式预估 100,000 视频 Token");
|
||||
expect(description).toContain("平台额外预留 10%");
|
||||
expect(description).toContain("15 秒");
|
||||
expect(description).toContain("并非费用上限");
|
||||
expect(description).toContain("未返回用量时按火山引擎公式结算");
|
||||
expect(description).toContain("额外预留不计入公式结算");
|
||||
expect(description).toContain("补扣或退回差额");
|
||||
});
|
||||
|
||||
test("text Token quotes retain upstream usage settlement without a video formula", () => {
|
||||
const description = modelQuoteDescription(quote);
|
||||
expect(description).toContain("实际用量结算");
|
||||
expect(description).not.toContain("火山引擎公式");
|
||||
});
|
||||
Reference in new issue
Block a user