feat(admin): add read-only pricing simulator
This commit is contained in:
@@ -47,3 +47,56 @@
|
||||
- `npm run test:db`:34 通过,0 失败;Docker 中 migration apply 与业务 schema 检查通过
|
||||
|
||||
价格门控仍生效:没有 staging/production 非零真实成本前,migration 未写入任何价格 seed。
|
||||
|
||||
## 任务 1 · 报告生成计费
|
||||
|
||||
已接入 `report.full` 的完整 `reserve → complete/release` 闭环:
|
||||
|
||||
- 日限检查之后、创建报告之前 reserve;创建或入队失败立即 release。
|
||||
- inline 成功 complete;durable worker 成功 complete,终态失败 release,retryable failure 在重试耗尽前不 release。
|
||||
- request id 使用既有 `payload.requestId`,保持幂等。
|
||||
- token 按 plan、章节、summary、repair 的实际调用累计后 complete;不使用伪造的固定 token 数。
|
||||
|
||||
## 任务 2 · 校正按功能定价
|
||||
|
||||
已接入 `resolveFeaturePricing(accounting, "rectification", selectedModel.id)`,不再把模型 `creditCost` 当作校正价格。继续使用既有 case 级幂等键 `rectification:case:<caseId>`,未修改公平使用、配额或 `model_not_included` SQL 校验。错误响应区分点数不足、分钟/日/月公平使用限制与 billing denied。
|
||||
|
||||
## 任务 3 · 对话按功能定价
|
||||
|
||||
已接入 `resolveFeaturePricing(accounting, "chat.standard", model.id)`,仍使用既有 `reserve_consultation_usage` RPC,仅替换 `p_credit_cost` 的来源为服务端已发布功能价格。
|
||||
|
||||
## 任务 4 · 上下文缓存
|
||||
|
||||
Blocked/skipped:当前没有 staging/production 的真实成本与 provider 缓存能力证据;本轮不接缓存,也不引入 provider 特殊降级逻辑。待有真实成本与 provider 级缓存计价/兼容性数据后再单独评估。
|
||||
|
||||
## 任务 5 · 会员档参数与权益
|
||||
|
||||
Blocked:未取得 staging/production 非零真实单位成本,不能重设 `minuteLimit`、`dayLimit`、`billingLimit`,不能移除 `rectification` / `report.full` 现有权益行,也不修改商品售价。保持现有公平使用与配额配置不变。
|
||||
|
||||
## 任务 6 · 管理端定价测算页
|
||||
|
||||
已完成只读 `/admin/pricing-simulator`:
|
||||
|
||||
- 数据来自模型价格、功能定价、商品/权益与账本聚合端点;页面仅调用 GET,未提供保存价格入口。
|
||||
- 单功能、会员平均/打满、盈亏平衡与 21 秒串行日吞吐/会员承载量均由纯函数实时计算。
|
||||
- 权重不为 1 时自动归一化;`billingLimit=null` 显示不限;账本无数据显示“无实测数据”,不转成 0。
|
||||
- 模型价格为 0、账本无数据、权重归一化与不限配额均有单测。
|
||||
- 空数据、分布权重、汇率固定规则之外的固定成本与用户画像均保持“假设/待填”语义;未写入任何价格 seed。固定换算规则为 1 元人民币 = 10 积分。
|
||||
|
||||
## 验证与剩余环境缺口
|
||||
|
||||
已通过:
|
||||
|
||||
- `./node_modules/.bin/tsc --noEmit`
|
||||
- `./node_modules/.bin/tsx --test tests/personal-report-api.test.ts tests/personal-report-worker.test.ts tests/feature-pricing-contract.test.ts tests/application-billing-contract.test.ts tests/pricing-simulation.test.ts`(72 passed)
|
||||
- `npm run lint`(0 errors,23 个既有 warnings)
|
||||
- `npm run test:db`(34 passed,0 failed,Docker migration/schema tests)
|
||||
- `npm run build`(成功;仅既有 Turbopack dynamic filesystem tracing warnings)
|
||||
|
||||
`npm run db:migrate:check` 尚未能执行有效检查:环境未提供 `SCHEMA_DATABASE_URL`,命令会 fail-closed 为 `SCHEMA_DATABASE_URL is required`。补充 schema database 连接串后需连续运行三次;当前不因该环境缺口修改迁移内容。
|
||||
|
||||
本轮不 push、不 merge、不 rebase;主工作树保持不变。
|
||||
|
||||
### 全量测试补充
|
||||
|
||||
`./node_modules/.bin/tsx --test tests/*.test.ts`:2369 passed,1 failed。唯一失败为既有的 `tests/staging-backend-workflows.test.ts` YAML 语法检查,失败原因是运行环境的 Python 缺少 `yaml` 模块(`ModuleNotFoundError: No module named 'yaml'`);本轮未修改 `.gitea/workflows/**`,因此不是本轮回归。该环境缺口未通过新增依赖绕过。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PricingSimulator } from "@/components/admin/pricing-simulator";
|
||||
|
||||
export default function PricingSimulatorPage() {
|
||||
return <PricingSimulator />;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Card, Col, Divider, InputNumber, Row, Space, Statistic, Table, Tag, Typography } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { adminRequestJson } from "@/lib/admin/providers";
|
||||
import {
|
||||
CONSULTATION_DOMAIN_DURATION_MS,
|
||||
estimateFeature,
|
||||
memberCapacity,
|
||||
membershipCost,
|
||||
type DistributionBand,
|
||||
type Metric,
|
||||
type ModelPrice,
|
||||
type UsageAggregate,
|
||||
} from "@/lib/pricing-simulation";
|
||||
|
||||
const FEATURE_KEYS = ["chat.standard", "chat.premium", "rectification", "report.full"] as const;
|
||||
const BANDS: DistributionBand[] = [
|
||||
{ weight: 0, monthlyMessages: 0 },
|
||||
{ weight: 0, monthlyMessages: 0 },
|
||||
{ weight: 0, monthlyMessages: 0 },
|
||||
{ weight: 0, monthlyMessages: 0 },
|
||||
];
|
||||
|
||||
type ModelRow = ModelPrice & { modelId: string; status: string; enabled: boolean };
|
||||
type PricingRow = { featureKey: string; modelTier: string; creditCost: number; status: string; enabled: boolean };
|
||||
type Product = { code: string; priceCents: number; enabled: boolean; status: string; entitlements: Array<{ featureKey: string; metadata?: { minuteLimit?: number | null; dayLimit?: number | null; billingLimit?: number | null } }> };
|
||||
type UsagePayload = { data: Array<{ featureKey: string; hasData: boolean; metrics: { costMicrousd: Metric; inputTokens: Metric; outputTokens: Metric; durationMs: Metric } }> };
|
||||
|
||||
type LoadState = { models?: ModelRow[]; pricing?: PricingRow[]; products?: Product[]; usage?: UsagePayload; errors: string[] };
|
||||
|
||||
function metric(value: unknown): Metric {
|
||||
const data = value as Partial<Metric> | null;
|
||||
return { runs: Number(data?.runs ?? 0), avg: data?.avg ?? null, p50: data?.p50 ?? null, p95: data?.p95 ?? null, max: data?.max ?? null };
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null, suffix = "") {
|
||||
return value === null || !Number.isFinite(value) ? "—" : `${value.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}${suffix}`;
|
||||
}
|
||||
|
||||
function formatCny(value: number | null) {
|
||||
return value === null ? "—" : `¥${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function errorText(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function PricingSimulator() {
|
||||
const [state, setState] = useState<LoadState>({ errors: [] });
|
||||
const [usdToCny, setUsdToCny] = useState<number | null>(null);
|
||||
const [fixedCostCny, setFixedCostCny] = useState<number | null>(null);
|
||||
const [distribution, setDistribution] = useState<DistributionBand[]>(BANDS);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void Promise.allSettled([
|
||||
adminRequestJson<{ data: ModelRow[] }>("/api/admin/models?page=1&pageSize=100&status=published"),
|
||||
adminRequestJson<{ data: PricingRow[] }>("/api/admin/feature-pricing"),
|
||||
adminRequestJson<{ data: Product[] }>("/api/admin/products?page=1&pageSize=100&status=published"),
|
||||
adminRequestJson<UsagePayload>("/api/admin/usage/aggregate"),
|
||||
]).then((results) => {
|
||||
if (!active) return;
|
||||
const [models, pricing, products, usage] = results;
|
||||
setState({
|
||||
models: models.status === "fulfilled" ? models.value.data : undefined,
|
||||
pricing: pricing.status === "fulfilled" ? pricing.value.data : undefined,
|
||||
products: products.status === "fulfilled" ? products.value.data : undefined,
|
||||
usage: usage.status === "fulfilled" ? usage.value : undefined,
|
||||
errors: results.flatMap((result, index) => result.status === "rejected" ? [errorText(result.reason, `数据块 ${index + 1} 不可用`)] : []),
|
||||
});
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const usageByFeature = useMemo(() => new Map((state.usage?.data ?? []).map((row) => [row.featureKey, {
|
||||
hasData: row.hasData,
|
||||
metrics: {
|
||||
costMicrousd: metric(row.metrics.costMicrousd), inputTokens: metric(row.metrics.inputTokens),
|
||||
outputTokens: metric(row.metrics.outputTokens), durationMs: metric(row.metrics.durationMs),
|
||||
},
|
||||
} satisfies UsageAggregate])), [state.usage]);
|
||||
|
||||
const rows = useMemo(() => FEATURE_KEYS.map((featureKey) => {
|
||||
const pricing = (state.pricing ?? []).find((row) => row.featureKey === featureKey && row.status === "published" && row.enabled);
|
||||
const model = (state.models ?? []).find((row) => row.modelTier === pricing?.modelTier && row.status === "published" && row.enabled) ?? null;
|
||||
const estimate = estimateFeature({
|
||||
featureKey,
|
||||
creditCost: pricing?.creditCost ?? null,
|
||||
model,
|
||||
usage: usageByFeature.get(featureKey) ?? null,
|
||||
usdToCny,
|
||||
});
|
||||
return { key: featureKey, featureKey, pricing, model, usage: usageByFeature.get(featureKey) ?? null, estimate };
|
||||
}), [state.models, state.pricing, usageByFeature, usdToCny]);
|
||||
|
||||
const standardRow = rows.find((row) => row.featureKey === "chat.standard");
|
||||
const monthly = (state.products ?? []).find((product) => product.code === "standard_monthly" && product.status === "published");
|
||||
const monthlyEntitlement = monthly?.entitlements.find((entry) => entry.featureKey === "chat.standard");
|
||||
const averageCost = membershipCost(distribution, standardRow?.estimate.costCny ?? null, monthlyEntitlement?.metadata?.billingLimit ?? null);
|
||||
const monthlyPrice = monthly ? monthly.priceCents / 100 : null;
|
||||
const averageMargin = monthlyPrice === null || averageCost.averageCostCny === null ? null : ((monthlyPrice - averageCost.averageCostCny) / monthlyPrice) * 100;
|
||||
const worstMargin = monthlyPrice === null || averageCost.worstCostCny === null ? null : ((monthlyPrice - averageCost.worstCostCny) / monthlyPrice) * 100;
|
||||
const breakEven = fixedCostCny === null || monthlyPrice === null || averageMargin === null || averageMargin <= 0 ? null : Math.ceil(fixedCostCny / (monthlyPrice * averageMargin / 100));
|
||||
const capacity = memberCapacity(distribution, CONSULTATION_DOMAIN_DURATION_MS);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={18} style={{ display: "flex" }}>
|
||||
<Card title="定价测算" extra={<Button href="/admin/feature-pricing">前往功能定价</Button>}>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
只读测算页:价格和用量来自后台实时接口;本页输入仅用于当前浏览器内的假设,不会写入价格配置。
|
||||
</Typography.Paragraph>
|
||||
{state.errors.map((error) => <Alert key={error} type="warning" showIcon message="部分数据不可用" description={error} />)}
|
||||
</Card>
|
||||
|
||||
<Card title="可调假设">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}><Typography.Text>USD/CNY 汇率(假设)</Typography.Text><InputNumber min={0} value={usdToCny ?? undefined} onChange={setUsdToCny} placeholder="未设置" style={{ width: "100%" }} /></Col>
|
||||
<Col xs={24} md={8}><Typography.Text>固定成本 ¥/月(假设)</Typography.Text><InputNumber min={0} value={fixedCostCny ?? undefined} onChange={setFixedCostCny} placeholder="未设置" style={{ width: "100%" }} /></Col>
|
||||
<Col xs={24} md={8}><Typography.Text>会员月均消息分布(假设)</Typography.Text><Space.Compact style={{ width: "100%" }}><InputNumber min={0} value={distribution[0]?.weight} onChange={(value) => setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, weight: value ?? 0 } : item))} placeholder="占比" /><InputNumber min={0} value={distribution[0]?.monthlyMessages} onChange={(value) => setDistribution((items) => items.map((item, index) => index === 0 ? { ...item, monthlyMessages: value ?? 0 } : item))} placeholder="条/月" /></Space.Compact></Col>
|
||||
</Row>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: "12px 0 0" }}>
|
||||
首屏不填充没有实测依据的分布与汇率;其余三档可在下面表格继续编辑。权重会归一化,合计不必恰好等于 1。
|
||||
</Typography.Paragraph>
|
||||
<Table<DistributionBand> size="small" pagination={false} rowKey={(_, index) => String(index)} dataSource={distribution} columns={[
|
||||
{ title: "档位", render: (_, __, index) => `第 ${index + 1} 档` },
|
||||
{ title: "占比", render: (_, item, index) => <InputNumber min={0} value={item.weight} onChange={(value) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, weight: value ?? 0 } : entry))} /> },
|
||||
{ title: "条数/月", render: (_, item, index) => <InputNumber min={0} value={item.monthlyMessages} onChange={(value) => setDistribution((items) => items.map((entry, i) => i === index ? { ...entry, monthlyMessages: value ?? 0 } : entry))} /> },
|
||||
]} />
|
||||
</Card>
|
||||
|
||||
<Card title="单功能表(p50)">
|
||||
<Table dataSource={rows} rowKey="key" pagination={false} scroll={{ x: 920 }} columns={[
|
||||
{ title: "功能", dataIndex: "featureKey" },
|
||||
{ title: "模型档位", render: (_, row) => row.pricing?.modelTier ?? <Tag>无发布配置</Tag> },
|
||||
{ title: "售价", render: (_, row) => row.estimate.saleCny === null ? "无发布价格" : `${row.pricing?.creditCost} 积分 / ${formatCny(row.estimate.saleCny)}` },
|
||||
{ title: "单位成本", render: (_, row) => row.estimate.source === "unavailable" ? <Tag>无实测数据</Tag> : <>{formatCny(row.estimate.costCny)}<Typography.Text type="secondary" style={{ display: "block" }}>{formatNumber(row.estimate.costMicrousd, " µUSD")} · {row.estimate.source === "ledger" ? "账本实测" : "模型估算"}</Typography.Text></> },
|
||||
{ title: "毛利率", render: (_, row) => row.estimate.marginPercent === null ? "需汇率或价格" : `${row.estimate.marginPercent.toFixed(1)}%` },
|
||||
]} />
|
||||
</Card>
|
||||
|
||||
<Card title="标准月卡风险视图">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={6}><Statistic title="月卡售价" value={monthlyPrice === null ? "—" : monthlyPrice} prefix={monthlyPrice === null ? undefined : "¥"} /></Col>
|
||||
<Col xs={24} md={6}><Statistic title="加权平均成本" value={averageCost.averageCostCny === null ? "—" : averageCost.averageCostCny} prefix={averageCost.averageCostCny === null ? undefined : "¥"} /></Col>
|
||||
<Col xs={24} md={6}><Statistic title="平均毛利率" value={averageMargin === null ? "—" : averageMargin.toFixed(1)} suffix={averageMargin === null ? undefined : "%"} /></Col>
|
||||
<Col xs={24} md={6}><Statistic title="最坏毛利率" value={averageCost.unlimitedWorstCase ? "不限" : worstMargin === null ? "—" : worstMargin.toFixed(1)} suffix={averageCost.unlimitedWorstCase || worstMargin === null ? undefined : "%"} /></Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Typography.Paragraph type="secondary">
|
||||
billingLimit 是异常账号熔断,不是“随便聊”的总量承诺。当前会员参数:minuteLimit {formatNumber(monthlyEntitlement?.metadata?.minuteLimit ?? null)},dayLimit {formatNumber(monthlyEntitlement?.metadata?.dayLimit ?? null)},billingLimit {monthlyEntitlement?.metadata?.billingLimit === null || monthlyEntitlement?.metadata?.billingLimit === undefined ? "不限" : formatNumber(monthlyEntitlement.metadata.billingLimit)}。
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}><Statistic title="盈亏平衡月卡数" value={breakEven ?? "—"} /></Col>
|
||||
<Col xs={24} md={8}><Statistic title="21 秒串行日吞吐" value={formatNumber(Math.floor(86_400_000 / CONSULTATION_DOMAIN_DURATION_MS))} suffix="次" /></Col>
|
||||
<Col xs={24} md={8}><Statistic title="画像下可承载会员数" value={capacity ?? "—"} /></Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { estimateFeature, membershipCost, weightedAverageMessages } from "../src/lib/pricing-simulation.ts";
|
||||
|
||||
const metric = (value: number | null) => ({ runs: value === null ? 0 : 1, avg: value, p50: value, p95: value, max: value });
|
||||
|
||||
test("normalizes a distribution whose weights do not sum to one", () => {
|
||||
assert.equal(weightedAverageMessages([{ weight: 1, monthlyMessages: 10 }, { weight: 1, monthlyMessages: 20 }]), 15);
|
||||
});
|
||||
|
||||
test("billingLimit null is explicitly unlimited rather than zero", () => {
|
||||
const result = membershipCost([{ weight: 1, monthlyMessages: 10 }], 2, null);
|
||||
assert.equal(result.worstCostCny, null);
|
||||
assert.equal(result.unlimitedWorstCase, true);
|
||||
});
|
||||
|
||||
test("zero model prices remain a measured zero cost", () => {
|
||||
const result = estimateFeature({
|
||||
featureKey: "chat.standard",
|
||||
creditCost: 10,
|
||||
usdToCny: 7,
|
||||
model: { modelTier: "standard", inputCostMicrousdPerMillion: 0, outputCostMicrousdPerMillion: 0 },
|
||||
usage: { hasData: true, metrics: { costMicrousd: metric(0), inputTokens: metric(100), outputTokens: metric(20), durationMs: metric(1000) } },
|
||||
});
|
||||
assert.equal(result.costMicrousd, 0);
|
||||
assert.equal(result.costCny, 0);
|
||||
assert.equal(result.marginPercent, 100);
|
||||
assert.equal(result.source, "ledger");
|
||||
});
|
||||
|
||||
test("ledger with no data is not silently converted to zero", () => {
|
||||
const result = estimateFeature({
|
||||
featureKey: "report.full",
|
||||
creditCost: null,
|
||||
usdToCny: 7,
|
||||
model: null,
|
||||
usage: { hasData: false, metrics: { costMicrousd: metric(null), inputTokens: metric(null), outputTokens: metric(null), durationMs: metric(null) } },
|
||||
});
|
||||
assert.equal(result.costMicrousd, null);
|
||||
assert.equal(result.source, "unavailable");
|
||||
});
|
||||
Reference in New Issue
Block a user