8000 fix(ledger): stabilize updates and quota estimates for 0.9.2 by InfinityPacer · Pull Request #29 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [未发布]

## [0.9.2] - 2026-10-04

- 仅接入本机采集器时,页面也会自动显示新数据,并显示最近更新时间;支持暂停页面自动更新,回到页面时立即检查变化。
- 周额度预估结合多个消费区间,减少低使用率和重复采样引起的波动。早期参考上一周期,没有可用历史时保留加权粗估,账户详情说明估算依据。
- 桌面 Tokens 摘要补充每次请求平均用量,并集中显示模型输出总量;移动端保留紧凑三列布局。
- 仅额度快照或采集状态变化时,避免重复写入用量索引检查点,保留真实用量变化的增量更新。

## [0.9.1] - 2026-10-03

- 已归档账户在桌面和手机上显示累计用量,不再显示额度窗口。点击账户可直接查看请求明细;恢复账户后重新显示当前额度。
Expand Down
Binary file modified docs/images/mobile-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/web-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,19 @@

5 小时和 7 天窗口独立显示,过期窗口不沿用上一周期的百分比和统计。Claude 账户如果上报了 Fable 周额度,会在 7 天窗口下方单独显示一行。它只统计 Fable 模型的请求,费用和预估都不含其他模型;没有上报时不显示,也不按套餐推断有没有这项额度。账户整体 7 天额度用满时所有模型都不可用,这时只显示 7 天窗口。7 天额度有效时,5 小时窗口过期或暂时没有数据会保留位置并显示“等待更新”,收到新周期的额度数据后恢复;7 天额度用满时只显示 7 天窗口。页面停留或从后台恢复时也会检查到期时间。

七天预估是按已用比例推算的整周额度价值,使用同周期截至快照采样时刻的消费除以已用比例,不要求多个采样。手机上它跟在已用费用之后,例如“$22.24 · $61.78”表示已用 $22.24、整周额度约值 $61.78;桌面账户列表在右侧单独一栏显示。比例为零、缺失或窗口失效时不推算。快照可能包含其他入口的消费,而本地请求只覆盖已采集来源,因此预估不是供应商承诺额度。
七天预估表示按实际观测推算的整周额度价值。系统比较当前周期内多个额度快照之间的费用与百分比变化,每段至少跨越 5 个百分点,积累至少 3 个有效点对后取中位数。同一百分比的重复采样不会增加权重。5 小时窗口不做此预估。

本周期观测较少时优先参考同一来源的上一周期,有足够的多段观测后随当前观测跨度增加逐步调整,跨度达到 20 个百分点后完全使用本周期数据。上一周期也没有足够多段观测时,只有已观测到至少 10% 才把其加权粗估作为参考。没有可用历史时,按本周期不同百分比的累计消费加权粗估,较高百分比权重更大。账户详情说明估算依据。周期重置后重新积累当前样本,不参考久远周期;百分比回退后只使用回退后的观测。

手机上预估跟在已用费用之后,桌面账户列表在右侧单独显示。缺少有效比例、可计价消费或窗口失效时保留未知,不补零。快照可能包含其他入口的消费,本地请求只覆盖已采集来源,模型组合和供应商计量规则也可能变化,因此估值不是供应商承诺额度。

Tokens 总量由普通输入、缓存读取、缓存写入和输出组成。推理 Tokens 是输出子集,不能再相加。缺失金额保留未知;可计价部分的合计不代表完整费用。两种美元口径和订阅 Credits 的区别见[计价说明](pricing.md)。

## 更新与失败提示

连接了 Sub2API 时,从右上角「数据同步」启动采集或开启自动同步。同步失败不会用演示数据替换已有账本。只接入本机采集器时没有这个入口,采集器推送后报表在后台更新,采集器的状态在那台电脑上用 `meterleaf-collector status` 查看。
页面会自动检查账本变化,有新数据时更新报表,回到页面时立即检查。右上角显示最近成功接收数据或完成同步的时间,可在「数据同步」中暂停页面自动更新;暂停不会停止采集器推送或后台同步。

连接了 Sub2API 时,还可从「数据同步」启动采集或开启自动同步。只接入本机采集器时显示推送状态,不提供拉取同步按钮;采集器自身的状态在那台电脑上用 `meterleaf-collector status` 查看。同步失败不会用演示数据替换已有账本。

后台刷新期间保留上次成功报表。首次查询没有缓存时仍需等待;读取失败与上游同步失败是不同问题,按提示说明的出错步骤排查,不要先清库。部署与日志操作见[部署指南](deployment.md)。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.9.1",
"version": "0.9.2",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
259 changes: 259 additions & 0 deletions src/domain/quota-estimate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import Decimal from "decimal.js";

export interface QuotaEstimateObservation {
percent: number;
sampledAt: string;
}

export interface QuotaEstimateAmounts {
usd: string | null;
credits: string | null;
}

/** 金额必须累计自同一窗口起点并截止到给定采样时刻,缺价保留 null。 */
export type QuotaEstimateChargeReader = (
sampledAt: string,
) => QuotaEstimateAmounts;

type UnitEstimateReason =
"eligible" | "insufficient-data" | "unpriced" | "non-monotonic";

export interface QuotaSegmentEstimate extends QuotaEstimateAmounts {
/** 当前单调段的百分比跨度,不表示独立样本数量或统计置信度。 */
deltaPercent: number | null;
reason: "eligible" | "insufficient-data" | "unpriced";
units: Record<
"usd" | "credits",
{ reason: UnitEstimateReason; positivePairCount: number }
>;
}

const maxObservations = 32;
const minDeltaPercent = 5;
const minPositivePairs = 3;

function currentSegment(observations: readonly QuotaEstimateObservation[]) {
let previousPercent: number | null = null;
let segment: QuotaEstimateObservation[] = [];
for (const observation of observations) {
const { percent } = observation;
if (!Number.isFinite(percent) || percent < 0) continue;
if (previousPercent !== null && percent < previousPercent) segment = [];
previousPercent = percent;
// 饱和点不能外推,但从饱和值下降仍是单调段的边界。
if (percent >= 100) continue;
// 平台期只保留首次到达时刻,避免采样频率改变点对权重。
if (segment.at(-1)?.percent === percent) continue;
segment.push(observation);
}
return segment;
}

function representativePoints(
observations: readonly QuotaEstimateObservation[],
): readonly QuotaEstimateObservation[] {
if (observations.length <= maxObservations) return observations;
const first = observations[0]!;
const last = observations.at(-1)!;
const selected = new Set<number>([0, observations.length - 1]);
let index = 0;
// 在百分比范围均匀取目标值,使用最近的真实观测,不插值费用或伪造样本。
for (let targetIndex = 1; targetIndex < maxObservations - 1; targetIndex++) {
const target =
first.percent +
((last.percent - first.percent) * targetIndex) / (maxObservations - 1);
while (
index + 1 < observations.length &&
observations[index + 1]!.percent <= target
) {
index++;
}
const next = Math.min(index + 1, observations.length - 1);
selected.add(
target - observations[index]!.percent <=
observations[next]!.percent - target
? index
: next,
);
}
return [...selected]
.sort((a, b) => a - b)
.map((selectedIndex) => observations[selectedIndex]!);
}

function monotonicAmounts(
amounts: readonly (string | null)[],
): (Decimal | null)[] | null {
const cumulative = amounts.map((amount) => {
if (amount === null) return null;
const value = new Decimal(amount);
return value.isFinite() ? value : null;
});
let previous: Decimal | null = null;
for (const amount of cumulative) {
if (amount === null) continue;
// 累计费用下降使该单位不可外推,不能丢弃负差后仅采用正差。
if (previous !== null && amount.lt(previous)) {
return null;
}
previous = amount;
}
return cumulative;
}

function estimateUnit(
amounts: readonly (string | null)[],
pairs: readonly { from: number; to: number; delta: Decimal }[],
): {
amount: string | null;
reason: UnitEstimateReason;
positivePairCount: number;
} {
if (pairs.length < minPositivePairs) {
return { amount: null, reason: "insufficient-data", positivePairCount: 0 };
}
const cumulative = monotonicAmounts(amounts);
if (cumulative === null) {
return { amount: null, reason: "non-monotonic", positivePairCount: 0 };
}
const estimates: Decimal[] = [];
for (const pair of pairs) {
const from = cumulative[pair.from]!;
const to = cumulative[pair.to]!;
if (from === null || to === null) continue;
const difference = to.sub(from);
if (difference.gt(0)) estimates.push(difference.mul(100).div(pair.delta));
}
if (estimates.length < minPositivePairs) {
return {
amount: null,
reason: "unpriced",
positivePairCount: estimates.length,
};
}
estimates.sort((a, b) => a.comparedTo(b));
const middle = Math.floor(estimates.length / 2);
const median =
estimates.length % 2 === 0
? estimates[middle - 1]!.add(estimates[middle]!).div(2)
: estimates[middle]!;
return {
amount: median.toString(),
reason: "eligible",
positivePairCount: estimates.length,
};
}

/**
* 数据尚不足以形成多段估算时,按真实观测做过原点加权粗估。
* 权重为百分比平方,缺价点不参与对应单位的分子或分母。
* 调用方筛选窗口并排序,使用与多段估算相同的缓存读取器可复用费用查询。
*/
export function roughQuotaEstimate(
observations: readonly QuotaEstimateObservation[],
readCumulativeCharges: QuotaEstimateChargeReader,
minimumObservedPercent = 0,
): QuotaEstimateAmounts {
const selected = representativePoints(currentSegment(observations)).filter(
(observation) => observation.percent > 0,
);
const charges = selected.map((observation) =>
readCumulativeCharges(observation.sampledAt),
);
const roughUnit = (unit: "usd" | "credits") => {
const amounts = monotonicAmounts(charges.map((charge) => charge[unit]));
if (amounts === null) return null;
let numerator = new Decimal(0);
let denominator = new Decimal(0);
let enoughObservedPercent = false;
for (let index = 0; index < selected.length; index++) {
const amount = amounts[index]!;
if (amount === null) continue;
if (amount.gt(0) && selected[index]!.percent >= minimumObservedPercent)
enoughObservedPercent = true;
const percent = new Decimal(selected[index]!.percent);
numerator = numerator.add(percent.mul(amount));
denominator = denominator.add(percent.mul(percent));
}
// 首次平台观测可能早于本地费用到达,仅无正值时用最新平台费用补上粗估。
// 正常已有估值的平台仍固定首次点,避免每次推送都改变估值。
if (!numerator.gt(0)) {
const latest = observations.at(-1);
if (
latest &&
latest.percent > 0 &&
latest.percent < 100 &&
latest.percent >= minimumObservedPercent
) {
const latestAmount = readCumulativeCharges(latest.sampledAt)[unit];
if (latestAmount !== null) {
const value = new Decimal(latestAmount);
if (value.isFinite() && value.gt(0))
return value.mul(100).div(latest.percent).toString();
}
}
}
return enoughObservedPercent && numerator.gt(0) && denominator.gt(0)
? numerator.mul(100).div(denominator).toString()
: null;
};
return { usd: roughUnit("usd"), credits: roughUnit("credits") };
}

/**
* 调用方先筛选同一来源和当前窗口,并按 sampledAt 升序传入观测。
* 点对费用差抵消窗口初始偏移,点对中位数只是条件性估算,不是承诺额度。
* 每单位至少需要三个正值点对,百分比差不足 5 个百分点不参与外推。
*/
export function estimateQuotaSegments(
observations: readonly QuotaEstimateObservation[],
readCumulativeCharges: QuotaEstimateChargeReader,
): QuotaSegmentEstimate {
const segment = currentSegment(observations);
const selected = representativePoints(segment);
const deltaPercent = segment.length
? new Decimal(segment.at(-1)!.percent).sub(segment[0]!.percent).toNumber()
: null;
const pairs: { from: number; to: number; delta: Decimal }[] = [];
for (let from = 0; from < selected.length; from++) {
for (let to = from + 1; to < selected.length; to++) {
const delta = new Decimal(selected[to]!.percent).sub(
selected[from]!.percent,
);
if (delta.gte(minDeltaPercent)) pairs.push({ from, to, delta });
}
}
// 百分比变化尚不足时无需查询累计费用。
const charges =
pairs.length >= minPositivePairs
? selected.map((observation) =>
readCumulativeCharges(observation.sampledAt),
)
: [];
const usd = estimateUnit(
charges.map((charge) => charge.usd),
pairs,
);
const credits = estimateUnit(
charges.map((charge) => charge.credits),
pairs,
);
return {
usd: usd.amount,
credits: credits.amount,
deltaPercent,
reason:
usd.reason === "eligible" || credits.reason === "eligible"
? "eligible"
: pairs.length < minPositivePairs
? "insufficient-data"
: "unpriced",
units: {
usd: { reason: usd.reason, positivePairCount: usd.positivePairCount },
credits: {
reason: credits.reason,
positivePairCount: credits.positivePairCount,
},
},
};
}
Loading
Loading
0