155 lines
8.5 KiB
TypeScript
155 lines
8.5 KiB
TypeScript
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 { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
|
|
|
export type ModelPriceTier = NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number];
|
|
|
|
type ModelCreditCost = {
|
|
model: string;
|
|
pricePolicy?: "channel" | "unified";
|
|
billingMode: "fixed_request" | "per_second" | "token";
|
|
unitPriceMicrocredits: number;
|
|
logicalPriceTiers?: ModelPriceTier[];
|
|
};
|
|
|
|
export function requestCreditCost(options: { channelMode: string; modelCosts?: ModelCreditCost[]; model: string; count?: string | number; seconds?: string | number; capability?: ModelCapability; config?: AiConfig; requirements?: ModelRequirements }) {
|
|
if (options.channelMode !== "remote") return null;
|
|
const cost = options.modelCosts?.find((item) => item.model === options.model) || null;
|
|
if (!cost) return null;
|
|
if (cost.pricePolicy === "channel") {
|
|
if (!options.config) return null;
|
|
const tiers = priceTiersForCurrentSelection(cost.logicalPriceTiers || [], options.capability, options.config, options.requirements);
|
|
if (!tiers.length) return null;
|
|
const first = tiers[0];
|
|
if (!first || first.billingMode === "token") return null;
|
|
// 同一精确规格可能来自多个逻辑路由;只有价格一致时才可在客户端安全展示。
|
|
if (tiers.some((tier) => tier.billingMode !== first.billingMode || tier.unitPriceMicrocredits !== first.unitPriceMicrocredits)) return null;
|
|
return creditAmount(first.billingMode, first.unitPriceMicrocredits, options.count, options.seconds);
|
|
}
|
|
// Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。
|
|
if (cost.billingMode === "token") return null;
|
|
return creditAmount(cost.billingMode, cost.unitPriceMicrocredits, options.count, options.seconds);
|
|
}
|
|
|
|
export function priceTiersForCurrentSelection(tiers: ModelPriceTier[], capability: ModelCapability | undefined, config: AiConfig, requirements?: ModelRequirements) {
|
|
const requested = priceSelectorForRequest(capability, config, requirements);
|
|
let bestScore = -1;
|
|
let matched: ModelPriceTier[] = [];
|
|
for (const tier of tiers) {
|
|
const selector = priceSelectorForTier(tier);
|
|
const conditions = Object.entries(selector).filter(([, value]) => value && value !== "*");
|
|
if (conditions.some(([key, value]) => requested[key] !== value)) continue;
|
|
const score = conditions.length;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
matched = [tier];
|
|
} else if (score === bestScore) {
|
|
matched.push(tier);
|
|
}
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
export function priceTierSummaryLabel(tiers: ModelPriceTier[]) {
|
|
const unitPrices = (billingMode: ModelPriceTier["billingMode"]) =>
|
|
tiers
|
|
.filter((tier) => tier.billingMode === billingMode)
|
|
.map((tier) => tier.unitPriceMicrocredits / 1_000_000)
|
|
.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") ? "按量预估" : "未配置";
|
|
}
|
|
|
|
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 });
|
|
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 {
|
|
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;
|
|
const input = requirements?.input;
|
|
const intent: ModelRequestIntent = {
|
|
capability,
|
|
operation: capability === "image" ? imagePriceOperation(requirements) : capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation,
|
|
inputs: {
|
|
image: (input?.imageCount || 0) + (input?.characterCount || 0),
|
|
video: input?.videoCount || 0,
|
|
audio: input?.audioCount || 0,
|
|
},
|
|
options: {
|
|
...modelRequestOptions(config, capability),
|
|
...(requirements?.options || {}),
|
|
...(requirements?.videoSeconds ? { videoSeconds: Number(requirements.videoSeconds) } : {}),
|
|
...(requirements?.imageSize ? { size: requirements.imageSize } : {}),
|
|
},
|
|
};
|
|
return { logicalModelID: cost.logicalModelId, intent };
|
|
}
|
|
|
|
function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) {
|
|
const quantity = billingMode === "per_second" ? Math.max(1, Math.floor(Math.abs(Number(seconds)) || 1)) : Math.max(1, Math.floor(Math.abs(Number(count)) || 1));
|
|
return (unitPriceMicrocredits / 1_000_000) * quantity;
|
|
}
|
|
|
|
function priceSelectorForRequest(capability: ModelCapability | undefined, config: AiConfig, requirements?: ModelRequirements) {
|
|
const requested: Record<string, string> = {};
|
|
if (capability === "video") {
|
|
const input = requirements?.input;
|
|
if (input) {
|
|
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);
|
|
if (resolution !== "*") requested.vquality = resolution;
|
|
const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0));
|
|
if (seconds > 0) requested.videoSeconds = String(seconds);
|
|
}
|
|
if (capability === "image") {
|
|
requested.operation = imagePriceOperation(requirements);
|
|
const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
|
const imageQuality = String(options.quality ?? "").trim().toLowerCase();
|
|
const imageSize = String(options.size ?? "").trim();
|
|
if ((imageQuality === "" || imageQuality === "auto" || imageQuality === "any") && imageSize) {
|
|
const resolution = imageResolutionOption(buildImageResolutionOptions([imageSize]), imageSize)?.tier;
|
|
if (resolution) requested.quality = resolution;
|
|
}
|
|
for (const key of ["quality", "size"] as const) {
|
|
const value = String(options[key] ?? "").trim().toLowerCase();
|
|
if (value && value !== "auto" && value !== "any" && !requested[key]) requested[key] = value;
|
|
}
|
|
}
|
|
return requested;
|
|
}
|
|
|
|
function imagePriceOperation(requirements?: ModelRequirements) {
|
|
const input = requirements?.input;
|
|
return (input?.imageCount || 0) + (input?.characterCount || 0) > 0 ? "image_to_image" : "text_to_image";
|
|
}
|
|
|
|
function priceSelectorForTier(tier: ModelPriceTier) {
|
|
const selector = { ...(tier.selector || {}) };
|
|
if (!Object.keys(selector).length) {
|
|
const resolution = normalizeTierResolution(tier.resolution);
|
|
if (resolution !== "*") selector.vquality = resolution;
|
|
if (tier.videoSeconds > 0) selector.videoSeconds = String(tier.videoSeconds);
|
|
}
|
|
return selector;
|
|
}
|
|
|
|
export function normalizeTierResolution(value: string) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw || raw === "*") return "*";
|
|
return videoResolutionComparisonKey(raw);
|
|
}
|