8000 fix(quota): ignore stale quota readings for 0.9.6 by InfinityPacer · Pull Request #33 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
8000
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [未发布]

## [0.9.6] - 2026-10-07

- 修复七天额度预估在百分比短暂回落 1 个点后跌到几十美元的问题。多个 Claude Code 会话写入的过时读数不再让预估从零重新起算,进度也不再在相邻两个值之间来回跳。
- 周期初期的预估按上游百分比取整规则校正,1% 到 5% 时不再明显偏低,切换到多段估算时也不再跳变。

## [0.9.5] - 2026-10-06

- 页面冷启动时按需加载账户趋势图,减少首页启动需要下载和执行的脚本。
Expand Down
6 changes: 4 additions & 2 deletions docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@

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

七天预估表示按实际观测推算的整周额度价值。系统比较当前周期内多个额度快照之间的费用与百分比变化,每段至少跨越 5 个百分点,积累至少 3 个有效点对后取中位数。同一百分比的重复采样不会增加权重。5 小时窗口不做此预估。
七天预估表示按实际观测推算的整周额度价值。系统比较当前周期内多个额度快照之间的费用与百分比变化,每段至少跨越 5 个百分点,积累至少 3 个有效点对后取中位数。同一百分比的重复采样不会增加权重。上游百分比是四舍五入后的整数,刚从 k-1 变为 k 的时刻按 k-0.5 计算,避免周期初期低估。5 小时窗口不做此预估。

同一周期内百分比只会上升。同时开着多个 Claude Code 会话时,某个会话可能写入它较早看到的额度,使百分比暂时回落几个点。这类小幅回落按过时读数忽略,进度和预估都保持此前的最高值;重置时间只差几毫秒的快照也归入同一周期。

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

服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比回退也会重新取基准。刚发生变化、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。
服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 5 个百分点且不高于此前最高值一半)也会重新取基准。刚发生变化、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。

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

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.5",
"version": "0.9.6",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
33 changes: 29 additions & 4 deletions src/domain/quota-estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,40 @@ function estimateUnit(
};
}

/**
* 上游把百分比四舍五入为整数时,紧接 k-1 之后首次出现的 k 实际约为 k-0.5。
* 只有相邻整数之间的首次到达才能确定跨越时刻;跳跃到达或非整数读数保持原值。
* 周期初期粗估直接除以百分比,不校正会在 1%、2%、3% 时分别低估约一半、四分之一和六分之一。
*/
function roundedArrivals(
segment: readonly QuotaEstimateObservation[],
): QuotaEstimateObservation[] {
return segment.map((observation, index) => {
const previous = segment[index - 1];
return previous &&
Number.isInteger(observation.percent) &&
previous.percent === observation.percent - 1
? { ...observation, percent: observation.percent - 0.5 }
: observation;
});
}

/**
* 数据尚不足以形成多段估算时,按真实观测做过原点加权粗估。
* 权重为百分比平方,缺价点不参与对应单位的分子或分母。
* 调用方筛选窗口并排序,使用与多段估算相同的缓存读取器可复用费用查询。
* roundedPercent 表示上游百分比为四舍五入整数,首次到达点按跨越时刻校正。
*/
export function roughQuotaEstimate(
observations: readonly QuotaEstimateObservation[],
readCumulativeCharges: QuotaEstimateChargeReader,
minimumObservedPercent = 0,
roundedPercent = false,
): QuotaEstimateAmounts {
const selected = representativePoints(currentSegment(observations)).filter(
(observation) => observation.percent > 0,
);
const segment = currentSegment(observations);
const selected = representativePoints(
roundedPercent ? roundedArrivals(segment) : segment,
).filter((observation) => observation.percent > 0);
const charges = selected.map((observation) =>
readCumulativeCharges(observation.sampledAt),
);
Expand Down Expand Up @@ -204,13 +225,17 @@ export function roughQuotaEstimate(
* 调用方先筛选同一来源和当前窗口,并按 sampledAt 升序传入观测。
* 点对费用差抵消窗口初始偏移,点对中位数只是条件性估算,不是承诺额度。
* 每单位至少需要三个正值点对,百分比差不足 5 个百分点不参与外推。
* roundedPercent 与粗估相同,校正后周期初期从粗估切换到多段估算时不会跳变。
*/
export function estimateQuotaSegments(
observations: readonly QuotaEstimateObservation[],
readCumulativeCharges: QuotaEstimateChargeReader,
roundedPercent = false,
): QuotaSegmentEstimate {
const segment = currentSegment(observations);
const selected = representativePoints(segment);
const selected = representativePoints(
roundedPercent ? roundedArrivals(segment) : segment,
);
const deltaPercent = segment.length
? new Decimal(segment.at(-1)!.percent).sub(segment[0]!.percent).toNumber()
: null;
Expand Down
140 changes: 91 additions & 49 deletions src/domain/quota.ts
8000
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,33 @@ export interface QuotaView {
};
}

/** 上游重置时间带毫秒级抖动,按最接近的整分钟归组,同一周期不能因抖动拆成两个。 */
function cycleResetKey(resetsAt: string | null) {
const value = resetsAt ? Date.parse(resetsAt) : NaN;
return Number.isFinite(value) ? Math.round(value / 60_000) * 60_000 : NaN;
}

/**
* 同一周期、同一套餐内已用百分比只增不减。多个 Claude Code 会话会轮流写入各自较早看到的额度,
* 小幅回落是过时读数,忽略后保留此前最高值;至少回落 5 个百分点且不高于此前最高值一半时,
* 才视为上游重新计数,从回落点重新起算。
*/
function stableReadings<T extends { percent: number }>(readings: readonly T[]) {
let kept: T[] = [];
let rebased = false;
for (const reading of readings) {
const highest = kept.at(-1)?.percent;
if (highest !== undefined && reading.percent < highest) {
if (highest - reading.percent < 5 || reading.percent > highest / 2)
continue;
kept = [];
rebased = true;
}
kept.push(reading);
}
return { readings: kept, rebased };
}

function sumCharges(rows: readonly PricedUsage[], unit: "usd" | "credits") {
// 周期费用累计可计价部分;只有有请求且全部缺价时金额才未知。
const priced = rows.filter((row) => row.valuation[unit].amount !== null);
Expand Down Expand Up @@ -139,7 +166,7 @@ export function quotaView(
);
const latest = ordered.at(-1)!;
const fact = latest.fact;
const reset = fact.resetsAt ? Date.parse(fact.resetsAt) : NaN;
const reset = cycleResetKey(fact.resetsAt);
const sampled = fact.sampledAt ? Date.parse(fact.sampledAt) : NaN;
const time = Date.parse(now);
const expired = Number.isFinite(reset) && reset <= time;
Expand All @@ -166,9 +193,58 @@ export function quotaView(
? readCharges(startsAt!, now)
: { usd: "0", credits: "0", count: 0, tokens: 0 }
: null;
// 合并账户可能同时持有多个采样来源,只用最新来源比较百分比。
const sameSource = ordered.filter(
({ fact: sample }) =>
sample.sourceId === fact.sourceId &&
sample.accountExternalId === fact.accountExternalId &&
sample.window === fact.window &&
sample.windowMinutes === fact.windowMinutes,
);
const plan = [...(latest.planHistory ?? [])]
.filter((entry) => Date.parse(entry.observedAt) <= time)
.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt))
.at(-1);
const planStart = plan ? Date.parse(plan.observedAt) : NaN;
const duration = (fact.windowMinutes ?? 0) * 60_000;
/** 返回某周期内同一来源、同一已知套餐区间的有效读数,过时回落已剔除。 */
const cycleReadings = (cycleReset: number, until: number) => {
const cycleStart = cycleReset - duration;
// 已发现套餐变化后不能使用变化前的点。初次建立历史不冒充过去的套餐证明。
const boundary =
plan && (plan.changed || cycleReset !== reset)
? Math.max(cycleStart, planStart)
: cycleStart;
const observations = sameSource.flatMap(({ fact: sample }) => {
const at = Date.parse(sample.sampledAt ?? "");
if (
cycleResetKey(sample.resetsAt) !== cycleReset ||
sample.percent === null ||
!Number.isFinite(sample.percent) ||
sample.percent < 0 ||
!Number.isFinite(at) ||
at < boundary ||
at > until ||
at >= cycleReset
)
return [];
return [{ percent: sample.percent, sampledAt: sample.sampledAt! }];
});
const stable = stableReadings(observations);
return {
observations: stable.readings,
rebased: stable.rebased || boundary > cycleStart,
};
};
// 展示的百分比同样忽略过时回落,避免进度在相邻两个值之间来回跳。
const displayPercent =
known && !expired && fact.percent !== null && fact.percent >= 0
? (cycleReadings(reset, sampled).observations.at(-1)?.percent ??
fact.percent)
: fact.percent;
const result: QuotaView = {
window: fact.window,
percent: expired ? 0 : fact.percent,
percent: expired ? 0 : displayPercent,
sampledAt: fact.sampledAt,
resetsAt: fact.resetsAt,
startsAt: expired ? null : startsAt,
Expand Down Expand Up @@ -200,53 +276,12 @@ export function quotaView(
fact.window === "five-hour"
)
return result;
// 合并账户可能同时持有多个采样来源,只用最新来源比较百分比。
const sameSource = ordered.filter(
({ fact: sample }) =>
sample.sourceId === fact.sourceId &&
sample.accountExternalId === fact.accountExternalId &&
sample.window === fact.window &&
sample.windowMinutes === fact.windowMinutes,
);
const plan = [...(latest.planHistory ?? [])]
.filter((entry) => Date.parse(entry.observedAt) <= time)
.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt))
.at(-1);
const planStart = plan ? Date.parse(plan.observedAt) : NaN;
const duration = fact.windowMinutes! * 60_000;
const estimateCycle = (cycleReset: number, until: number) => {
const cycleStart = cycleReset - duration;
// 已发现套餐变化后不能使用变化前的点。初次建立历史不冒充过去的套餐证明。
const boundary =
plan && (plan.changed || cycleReset !== reset)
? Math.max(cycleStart, planStart)
: cycleStart;
let observations = sameSource.flatMap(({ fact: sample }) => {
const at = Date.parse(sample.sampledAt ?? "");
if (
Date.parse(sample.resetsAt ?? "") !== cycleReset ||
sample.percent === null ||
!Number.isFinite(at) ||
at < boundary ||
at > until ||
at >= cycleReset
)
return [];
return [{ percent: sample.percent, sampledAt: sample.sampledAt! }];
});
let rebased = boundary > cycleStart;
for (let index = 1; index < observations.length; index++) {
if (observations[index]!.percent < observations[index - 1]!.percent) {
observations = observations.slice(index);
index = 0;
rebased = true;
}
}
observations = observations.filter(
(entry) =>
Number.isFinite(entry.percent) &&
entry.percent >= 0 &&
entry.percent < 100,
const readings = cycleReadings(cycleReset, until);
const rebased = readings.rebased;
const observations = readings.observations.filter(
(entry) => entry.percent < 100,
);
const baseline = rebased ? observations[0] : undefined;
const charges = new Map<string, QuotaEstimateAmounts>();
Expand Down Expand Up @@ -278,18 +313,25 @@ export function quotaView(
percent: new Decimal(entry.percent).sub(baseline.percent).toNumber(),
}))
: observations;
const segments = estimateQuotaSegments(estimateObservations, estimateRead);
// 上游百分比为四舍五入整数。重新起算的基准点落在平台期中段,相对百分比的取整偏差无法确定,不做校正。
const roundedPercent = !rebased;
const segments = estimateQuotaSegments(
estimateObservations,
estimateRead,
roundedPercent,
);
const rough = roughQuotaEstimate(
estimateObservations,
estimateRead,
cycleReset === reset ? 0 : 10,
roundedPercent,
);
return { segments, rough, observations, rebased };
};
const current = estimateCycle(reset, sampled);
// 只借用紧邻的上一周期;缺少上一周期或长时间停用后,不沿用久远额度。
const previousReset = sameSource.reduce((latestReset, { fact: sample }) => {
const candidate = Date.parse(sample.resetsAt ?? "");
const candidate = cycleResetKey(sample.resetsAt);
return candidate <= start &&
candidate > start - duration &&
candidate > latestReset
Expand Down
55 changes: 55 additions & 0 deletions tests/quota-estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,58 @@ test("a zero-cost first plateau does not hide later positive rough usage", () =>
),
).toEqual({ usd: "1000", credits: "2000" });
});

test("rounded integer percents treat adjacent first arrivals as half-point crossings", () => {
// 上游四舍五入时,紧接 k-1 后首次出现 k 的时刻实际约为 k-0.5。
const percents = [0, 1, 2, 3];
const samples = observations(percents);
const byTime = new Map(
samples.map((sample, index) => {
const crossed = Math.max(0, percents[index]! - 0.5);
return [
sample.sampledAt,
{ usd: String(crossed * 10), credits: String(crossed * 250) },
];
}),
);
const reader = (sampledAt: string) => byTime.get(sampledAt)!;
expect(roughQuotaEstimate(samples, reader, 0, true)).toEqual({
usd: "1000",
credits: "25000",
});
// 未声明取整时保持原百分比,结果明显偏低。
expect(Number(roughQuotaEstimate(samples, reader).usd)).toBeLessThan(900);
});

test("rounded correction skips jumped arrivals and fractional readings", () => {
for (const percents of [
[0, 10],
[0, 0.4, 1.4],
]) {
const samples = observations(percents);
const charges = linearCharges(percents);
const byTime = new Map(
samples.map((sample, index) => [sample.sampledAt, charges[index]!]),
);
expect(
roughQuotaEstimate(samples, (at) => byTime.get(at)!, 0, true).usd,
).toBe("1000");
}
});

test("rounded correction keeps segment estimates continuous with early rough estimates", () => {
const percents = [0, 1, 2, 3, 4, 5, 6, 7];
const samples = observations(percents);
const byTime = new Map(
samples.map((sample, index) => {
const crossed = Math.max(0, percents[index]! - 0.5);
return [
sample.sampledAt,
{ usd: String(crossed * 10), credits: String(crossed * 250) },
];
}),
);
const result = estimateQuotaSegments(samples, (at) => byTime.get(at)!, true);
expect(result.usd).toBe("1000");
expect(result.credits).toBe("25000");
});
32 changes: 32 additions & 0 deletions tests/quota-plan-estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,35 @@ test("missing baseline pricing and saturated new-plan samples never fabricate an
)!.estimate.usd,
).toBeNull();
});

test("stale lower readings within one cycle neither rebase estimates nor lower the shown percent", () => {
// 多个会话轮流写入额度缓存时,较早的读数会让百分比暂时回落 1 至 5 个点。
const percents = [10, 20, 30, 29, 30, 25, 40, 39];
const current = percents.map((percent, hour) => sample(8, hour, percent));
const linear: QuotaChargeReader = (_, end) => {
const hour = new Date(end).getUTCHours();
const highest = Math.max(...percents.slice(0, hour + 1));
return { usd: String(highest * 10), credits: null };
};
const result = quotaView(current, linear, iso(8, 7))!;
expect(result.percent).toBe(40);
expect(result.estimate).toMatchObject({
usd: "1000",
methods: { usd: "segments" },
});
});

test("millisecond reset jitter stays in the same cycle", () => {
const current = [0, 10, 20, 30].map((percent, hour) =>
sample(8, hour, percent),
);
const jittered = sample(8, 4, 40);
jittered.fact.resetsAt = new Date(Date.parse(iso(15)) - 30).toISOString();
const linear: QuotaChargeReader = (_, end) => ({
usd: String(new Date(end).getUTCHours() * 100),
credits: null,
});
expect(
quotaView([...current, jittered], linear, iso(8, 4))!.estimate,
).toMatchObject({ usd: "1000", methods: { usd: "segments" } });
});
0