92 lines
4.1 KiB
TypeScript
92 lines
4.1 KiB
TypeScript
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);
|
|
}
|
|
|
|
// billingLimit is an anomaly-account circuit breaker, not the member-facing total allowance.
|
|
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));
|
|
}
|