feat(admin): add read-only pricing simulator

This commit is contained in:
Jesse_Chen
2026-08-31 04:30:40 +08:00
parent 6e13949d3b
commit a499c3444e
5 changed files with 351 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
export const CREDITS_PER_CNY = 10;
export const CONSULTATION_DOMAIN_DURATION_MS = 21_000;
const MICROUSD_PER_USD = 1_000_000;
export type Metric = Readonly<{ runs: number; avg: number | null; p50: number | null; p95: number | null; max: number | null }>;
export type UsageAggregate = Readonly<{
hasData: boolean;
metrics: Readonly<{ costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric }>;
}>;
export type ModelPrice = Readonly<{
modelTier: string;
inputCostMicrousdPerMillion: number;
outputCostMicrousdPerMillion: number;
}>;
export type DistributionBand = Readonly<{ weight: number; monthlyMessages: number }>;
export type FeatureSimulationInput = Readonly<{
featureKey: string;
creditCost: number | null;
model: ModelPrice | null;
usage: UsageAggregate | null;
usdToCny: number | null;
}>;
export type FeatureSimulation = Readonly<{
featureKey: string;
saleCny: number | null;
costMicrousd: number | null;
costCny: number | null;
marginPercent: number | null;
source: "ledger" | "model_estimate" | "unavailable";
}>;
function metricValue(metric: Metric, percentile: "p50" | "p95" | "max"): number | null {
return metric[percentile];
}
export function estimateFeature(input: FeatureSimulationInput, percentile: "p50" | "p95" | "max" = "p50"): FeatureSimulation {
const saleCny = input.creditCost === null ? null : input.creditCost / CREDITS_PER_CNY;
if (input.creditCost === null || !input.usage?.hasData) {
return { featureKey: input.featureKey, saleCny, costMicrousd: null, costCny: null, marginPercent: null, source: "unavailable" };
}
const observedCost = metricValue(input.usage.metrics.costMicrousd, percentile);
const modelCost = input.model && input.usage.metrics.inputTokens[percentile] !== null && input.usage.metrics.outputTokens[percentile] !== null
? (input.usage.metrics.inputTokens[percentile]! * input.model.inputCostMicrousdPerMillion
+ input.usage.metrics.outputTokens[percentile]! * input.model.outputCostMicrousdPerMillion) / 1_000_000
: null;
const costMicrousd = observedCost ?? modelCost;
const costCny = costMicrousd === null || input.usdToCny === null
? null
: (costMicrousd / MICROUSD_PER_USD) * input.usdToCny;
return {
featureKey: input.featureKey,
saleCny,
costMicrousd,
costCny,
marginPercent: costCny === null || saleCny === null || saleCny === 0 ? null : ((saleCny - costCny) / saleCny) * 100,
source: observedCost !== null ? "ledger" : costMicrousd === null ? "unavailable" : "model_estimate",
};
}
export function weightedAverageMessages(distribution: readonly DistributionBand[]): number | null {
const usable = distribution.filter((band) => Number.isFinite(band.weight) && band.weight > 0 && Number.isFinite(band.monthlyMessages));
const totalWeight = usable.reduce((sum, band) => sum + band.weight, 0);
if (totalWeight <= 0) return null;
return usable.reduce((sum, band) => sum + (band.weight / totalWeight) * Math.max(0, band.monthlyMessages), 0);
}
export function membershipCost(distribution: readonly DistributionBand[], unitCostCny: number | null, billingLimit: number | null) {
const averageMessages = weightedAverageMessages(distribution);
return {
averageMessages,
averageCostCny: averageMessages === null || unitCostCny === null ? null : averageMessages * unitCostCny,
worstCostCny: billingLimit === null || unitCostCny === null ? null : billingLimit * unitCostCny,
unlimitedWorstCase: billingLimit === null,
};
}
export function dailySerialCapacity(durationMs = CONSULTATION_DOMAIN_DURATION_MS): number | null {
if (!Number.isFinite(durationMs) || durationMs <= 0) return null;
return Math.floor(86_400_000 / durationMs);
}
export function memberCapacity(distribution: readonly DistributionBand[], unitDurationMs: number): number | null {
const averageMessages = weightedAverageMessages(distribution);
const dailyCapacity = dailySerialCapacity(unitDurationMs);
if (averageMessages === null || dailyCapacity === null || averageMessages <= 0) return null;
return Math.floor(dailyCapacity / (averageMessages / 30));
}