feat(admin): add read-only pricing simulator
This commit is contained in:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user