8000 fix(quota): converge weekly estimates to actual spend for 0.9.7 by InfinityPacer · Pull Request #34 · 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [未发布]

## [0.9.7] - 2026-10-07

- 七天额度预估改为本周期已花金额加上剩余部分的推算,用得越多越接近实际,用满时等于已花金额。

## [0.9.6] - 2026-10-07

- 修复七天额度预估在百分比短暂回落 1 个点后跌到几十美元的问题。多个 Claude Code 会话写入的过时读数不再让预估从零重新起算,进度也不再在相邻两个值之间来回跳。
Expand Down
4 changes: 2 additions & 2 deletions docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,13 @@

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

七天预估表示按实际观测推算的整周额度价值。系统比较当前周期内多个额度快照之间的费用与百分比变化,每段至少跨越 5 个百分点,积累至少 3 个有效点对后取中位数。同一百分比的重复采样不会增加权重。上游百分比是四舍五入后的整数,刚从 k-1 变为 k 的时刻按 k-0.5 计算,避免周期初期低估。5 小时窗口不做此预估。
七天预估表示用满本周期额度时预计的总花费,等于本周期已花金额加上剩余百分比按每 1% 花费推算的金额。用得越多,需要推算的部分越少,用满时预估就是已花金额。同样的 1% 折算成美元并不固定,例如上下文较长、缓存读取较多的请求按 API 价格更贵,所以周期初期的预估会随用法变化。每 1% 花费比较当前周期内多个额度快照之间的费用与百分比变化,每段至少跨越 5 个百分点,积累至少 3 个有效点对后取中位数。同一百分比的重复采样不会增加权重。上游百分比是四舍五入后的整数,刚从 k-1 变为 k 的时刻按 k-0.5 计算,避免周期初期低估。5 小时窗口不做此预估。

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

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

服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 5 个百分点且不高于此前最高值一半)也会重新取基准。刚发生变化、还没有两个可比较的新点时暂时无法估算,有有效增量后恢复粗估。周期累计用量仍保留变化前后的全部消费。套餐观测记录从升级后开始积累,不把当前套餐补写到旧快照,也不把服务端发现时间当作上游实际生效时间。上游没有提供套餐信息或套餐标签不变的额度调整,无法保证即时识别。
服务端发现套餐变化后立即停止参考旧套餐,只用之后的新快照计算消费和百分比增量;百分比大幅回退(至少 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.6",
"version": "0.9.7",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
14 changes: 14 additions & 0 deletions src/domain/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,20 @@ export function quotaView(
: "rough";
}
}
// 上面得到的是按每 1% 花费外推的整周价值,只用来估计剩余部分。已花金额确定,
// 剩余比例越小估值越接近已花,用满时正好相等。套餐中途变化时已花包含变化前消费,该周期估值偏高。
const remaining = new Decimal(100)
.sub(Math.min(Math.max(result.percent ?? 0, 0), 100))
.div(100);
for (const unit of ["usd", "credits"] as const) {
const quota = result.estimate[unit];
const spent = unit === "usd" ? result.periodUsd : result.periodCredits;
result.estimate[unit] =
quota === null || spent === null
? null
: new Decimal(spent).add(new Decimal(quota).mul(remaining)).toString();
if (result.estimate[unit] === null) methods[unit] = null;
}
result.estimate.deltaPercent = current.segments.deltaPercent;
result.estimate.methods = methods;
result.estimate.reason =
Expand Down
47 changes: 27 additions & 20 deletions tests/indexed-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import type { SyncStatus } from "../src/server/sync";
import { LedgerStore, type StoredUsage } from "../src/storage/ledger";
import { ReportIndex } from "../src/storage/report-index";
import { fullWeekEstimate } from "./quota-projection";

const now = "2026-09-09T01:00:00.000Z";
const sampledAt = "2026-09-08T12:00:00.000Z";
Expand Down Expand Up @@ -182,8 +183,8 @@ test("quota reader sums through now and estimates only through sampledAt", () =>

expect(view.periodUsd).toBe("3");
expect(view.periodCredits).toBe("6");
expect(view.estimate.usd).toBe("10");
expect(view.estimate.credits).toBe("20");
expect(fullWeekEstimate(view)).toBe("10");
expect(fullWeekEstimate(view, "credits")).toBe("20");
expect(calls).toContainEqual(["2026-09-08T00:00:00.000Z", now]);
for (const sample of history)
expect(calls).toContainEqual([
Expand Down Expand Up @@ -249,8 +250,11 @@ test("indexed snapshot aggregates child quotas without reading usage and keeps o
expect(snapshot.accounts[0]!.sevenDay).toMatchObject({
periodUsd: "3",
periodCredits: "6",
estimate: { usd: "10", credits: "20" },
});
expect(fullWeekEstimate(snapshot.accounts[0]!.sevenDay!)).toBe("10");
expect(fullWeekEstimate(snapshot.accounts[0]!.sevenDay!, "credits")).toBe(
"20",
);
expect(snapshot.accounts[1]).toMatchObject({
name: "Account deleted",
plan: "未提供",
Expand Down Expand Up @@ -300,7 +304,8 @@ test("the Fable weekly window reads only Fable usage and still estimates its bud
expect(scopes[0]!("claude-fable-5-1")).toBe(true);
expect(scopes[0]!("claude-fable-5")).toBe(true);
expect(scopes[0]!("claude-opus-5")).toBe(false);
expect(view.estimate).toMatchObject({ usd: "10", reason: "eligible" });
expect(view.estimate.reason).toBe("eligible");
expect(fullWeekEstimate(view)).toBe("10");

quotaView([quota("seven-day", 20)], reader, now);
expect(scopes.at(-1)).toBeNull();
Expand Down Expand Up @@ -328,8 +333,10 @@ test("row-based quota views exclude other models from the Fable window", () => {
expect(fable.periodRequests).toBe(3);
expect(week.periodRequests).toBe(6);
expect(fable.periodUsd).toBe(new Decimal(fableUsd).mul(3).toString());
expect(fable.estimate.usd).toBe(new Decimal(fableUsd).mul(10).toString());
expect(week.estimate.usd).toBe(
expect(fullWeekEstimate(fable)).toBe(
new Decimal(fableUsd).mul(10).toString(),
);
expect(fullWeekEstimate(week)).toBe(
new Decimal(fableUsd).add(opusUsd).mul(10).toString(),
);
});
Expand Down Expand Up @@ -382,7 +389,7 @@ test("a new cycle or percent rollback estimates only its current monotonic segme
resetsAt: "2026-09-14T00:00:00.000Z",
}),
);
expect(baseline.estimate.usd).toBe("5");
expect(fullWeekEstimate(baseline)).toBe("5");
const beforeRollback = priorCycle.map((sample, index) => ({
...sample,
fact: {
Expand Down Expand Up @@ -455,7 +462,7 @@ test("real indexed and row snapshots agree on multi-point weekly and Fable estim
const valuation = valueUsage(records[0]!, defaultPriceBook);
const charge =
basis === "api" ? valuation.apiUsd : valuation.subscriptionUsd;
expect(indexedAccount.sevenDayFable?.estimate?.usd).toBe(
expect(fullWeekEstimate(indexedAccount.sevenDayFable!)).toBe(
new Decimal(charge.amount!).mul(10).toString(),
);
expect(indexedAccount.sevenDayFable?.periodUsd).toBe(
Expand Down Expand Up @@ -503,8 +510,8 @@ test("previous weekly estimate stabilizes early usage and yields gradually to th
reader,
now,
)!;
expect(result.estimat F438 e.usd).toBe(expected);
expect(result.estimate.credits).toBe(
expect(fullWeekEstimate(result)).toBe(expected);
expect(fullWeekEstimate(result, "credits")).toBe(
new Decimal(expected).mul(10).toString(),
);
expect(result.estimate.methods).toEqual({ usd: method, credits: method });
Expand All @@ -523,10 +530,9 @@ test("historical fallback never crosses sources or reuses a distant or too-small
start === "2026-09-08T00:00:00.000Z"
? { usd: "2", credits: "20" }
: { usd: "10", credits: "100" };
expect(quotaView([prior, current], reader, now)!.estimate).toMatchObject({
usd: "100",
methods: { usd: "previous-period" },
});
const borrowed = quotaView([prior, current], reader, now)!;
expect(borrowed.estimate.methods?.usd).toBe("previous-period");
expect(fullWeekEstimate(borrowed)).toBe("100");
for (const overrides of [
{ sourceId: "other" },
{ accountExternalId: "other" },
Expand Down Expand Up @@ -605,11 +611,11 @@ test("row and indexed snapshots read the previous cycle outside the selected rep
expect(rows.accounts[0]!.sevenDay).toMatchObject({
periodUsd: charge.amount,
periodRequests: 1,
estimate: {
usd: new Decimal(charge.amount!).mul(10).toString(),
methods: { usd: "previous-period" },
},
estimate: { methods: { usd: "previous-period" } },
});
expect(fullWeekEstimate(rows.accounts[0]!.sevenDay!)).toBe(
new Decimal(charge.amount!).mul(10).toString(),
);
expect(rows.records).toHaveLength(1);
}
} finally {
Expand Down Expand Up @@ -642,8 +648,9 @@ test("historical reference is not pulled toward noisy rough usage before multi-s
};
for (let count = 1; count <= 3; count++) {
expect(
quotaView([...previous, ...current.slice(0, count)], read, now)!.estimate
.usd,
fullWeekEstimate(
quotaView([...previous, ...current.slice(0, count)], read, now)!,
),
).toBe("1000");
}
});
10 changes: 6 additions & 4 deletions tests/ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { valueUsage, type PriceBook } from "../src/domain/pricing";
import { quotaView } from "../src/domain/quota";
import { LedgerStore } from "../src/storage/ledger";
import { SyncRunner } from "../src/server/sync";
import { fullWeekEstimate } from "./quota-projection";

const book: PriceBook = {
schemaVersion: 1,
Expand Down Expand Up @@ -507,16 +508,17 @@ test("early weekly observations use a rough estimate without requiring a segment
quota(25, "2026-09-08T02:00:00Z"),
];
const view = quotaView(history, usage, "2026-09-08T02:01:00Z")!;
expect(view.estimate.usd).toBe(
expect(fullWeekEstimate(view)).toBe(
new Decimal("0.001245")
.mul(25)
.mul(100)
.div(20 ** 2 + 25 ** 2)
.toDecimalPlaces(12)
.toString(),
);
expect(view.estimate.methods?.usd).toBe("rough");
const single = quotaView([history[1]!], usage, "2026-09-08T02:01:00Z")!;
expect(single.estimate.usd).toBe("0.00498");
expect(fullWeekEstimate(single)).toBe("0.00498");
expect(single.estimate.reason).toBe("eligible");
expect(single.estimate.methods?.usd).toBe("rough");
expect(
Expand Down Expand Up @@ -595,7 +597,7 @@ test("quota amounts and estimates keep priced spend when another request is unpr
[...priced, ...unpriced],
"2026-09-08T04:01:00Z",
)!;
expect(baseline.estimate.usd).toBe(
expect(fullWeekEstimate(baseline)).toBe(
new Decimal(valued.usd.amount!).mul(10).toString(),
);
expect(view.periodUsd).toBe(baseline.periodUsd);
Expand All @@ -606,7 +608,7 @@ test("quota amounts and estimates keep priced spend when another request is unpr
expect(unknown.periodUsd).toBeNull();
expect(unknown.estimate.usd).toBeNull();
expect(unknown.periodCredits).not.toBeNull();
expect(unknown.estimate.credits).toBe(
expect(fullWeekEstimate(unknown, "credits")).toBe(
new Decimal(valued.credits.amount!).mul(10).toString(),
);
});
Expand Down
57 changes: 37 additions & 20 deletions tests/quota-plan-estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type QuotaChargeReader,
} from "../src/domain/quota";
import type { QuotaPlanObservation } from "../src/domain/quota-plan";
import { fullWeekEstimate } from "./quota-projection";

const iso = (day: number, hour = 0) =>
new Date(Date.UTC(2026, 8, day, hour)).toISOString();
Expand Down Expand Up @@ -64,17 +65,16 @@ test("legacy and unknown plans never borrow previous-period estimates", () => {
...s,
planHistory,
}));
expect(quotaView(samples, read, iso(8, 4))!.estimate).toMatchObject({
usd: "5000",
methods: { usd: "rough" },
});
const result = quotaView(samples, read, iso(8, 4))!;
expect(result.estimate.methods?.usd).toBe("rough");
expect(fullWeekEstimate(result)).toBe("5000");
}
});

test("same known continuous plan retains early historical reference", () => {
expect(
quotaView([...prior, sample(8, 0, 1)], read, iso(8, 4))!.estimate,
).toMatchObject({ usd: "100", methods: { usd: "previous-period" } });
const result = quotaView([...prior, sample(8, 0, 1)], read, iso(8, 4))!;
expect(result.estimate.methods?.usd).toBe("previous-period");
expect(fullWeekEstimate(result)).toBe("100");
});

test("cross-cycle plan changes reject previous plan and keep current-cycle rough", () => {
Expand All @@ -83,10 +83,9 @@ test("cross-cycle plan changes reject previous plan and keep current-cycle rough
...s,
planHistory: plans,
}));
expect(quotaView(samples, read, iso(8, 4))!.estimate).toMatchObject({
usd: "7000",
methods: { usd: "rough" },
});
const result = quotaView(samples, read, iso(8, 4))!;
expect(result.estimate.methods?.usd).toBe("rough");
expect(fullWeekEstimate(result)).toBe("7000");
});

test("mid-cycle plan change with increasing percentages rebases both money and percent", () => {
Expand All @@ -113,7 +112,7 @@ test("mid-cycle plan change with increasing percentages rebases both money and p
planHistory: plans,
}));
const result = quotaView(samples, read, iso(8, 4))!;
expect(result.estimate.usd).toBe(expected);
expect(fullWeekEstimate(result)).toBe(expected);
expect(result.periodUsd).toBe("92");
expect(result.estimate.methods?.usd).not.toBe("previous-period");
expect(result.estimate.methods?.usd).not.toBe("blended");
Expand All @@ -131,20 +130,17 @@ test("returning to same plan after unknown or another plan cannot reconnect old
...s,
planHistory: plans,
}));
expect(quotaView(samples, read, iso(8, 4))!.estimate).toMatchObject({
usd: "200",
methods: { usd: "rough" },
});
const result = quotaView(samples, read, iso(8, 4))!;
expect(result.estimate.methods?.usd).toBe("rough");
expect(fullWeekEstimate(result)).toBe("200");
}
});

test("percent rollback without plan metadata also isolates money and disables prior", () => {
const current = [sample(8, 0, 40), sample(8, 1, 10), sample(8, 2, 11)];
const result = quotaView([...prior, ...current], read, iso(8, 4))!;
expect(result.estimate).toMatchObject({
usd: "200",
methods: { usd: "rough" },
});
expect(result.estimate.methods?.usd).toBe("rough");
expect(fullWeekEstimate(result)).toBe("200");
});

test("missing baseline pricing and saturated new-plan samples never fabricate an estimate", () => {
Expand Down Expand Up @@ -195,3 +191,24 @@ test("millisecond reset jitter stays in the same cycle", () => {
quotaView([...current, jittered], linear, iso(8, 4))!.estimate,
).toMatchObject({ usd: "1000", methods: { usd: "segments" } });
});

test("estimates add the remaining share to actual spend and converge to it when exhausted", () => {
// 前半段每 1% 花 15,后半段每 1% 花 10;整周外推值不变时,已花占比越高越接近实际。
const spentAt = (percent: number) =>
percent <= 50 ? percent * 15 : 750 + (percent - 50) * 10;
const percents = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90];
const current = percents.map((percent, hour) => sample(8, hour, percent));
const reader: QuotaChargeReader = (_, end) => {
const hour = Math.min(new Date(end).getUTCHours(), percents.length - 1);
return { usd: String(spentAt(percents[hour]!)), credits: null };
};
const late = quotaView(current, reader, iso(8, 9))!;
expect(late.periodUsd).toBe("1150");
// 只用整周外推会停在 1300;按后半段花费继续用满实际为 1250。
expect(fullWeekEstimate(late)).toBe("1300");
expect(late.estimate.usd).toBe("1280");

const exhausted = [...current, sample(8, 10, 100)];
const full = quotaView(exhausted, reader, iso(8, 10))!;
expect(full.estimate.usd).toBe(full.periodUsd);
});
29 changes: 29 additions & 0 deletions tests/quota-projection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Decimal from "decimal.js";

interface ProjectedWindow {
percent: number | null;
periodUsd?: string | null;
periodCredits?: string | null;
estimate?: { usd: string | null; credits: string | null };
}

/**
* 预估为已花加剩余比例乘整周外推值。反推出外推值,
* 让历史选择、加权与回退相关的断言继续检查外推本身。
*/
export function fullWeekEstimate(
view: ProjectedWindow,
unit: "usd" | "credits" = "usd",
): string | null {
const estimate = view.estimate?.[unit] ?? null;
const spent = (unit === "usd" ? view.periodUsd : view.periodCredits) ?? null;
if (estimate === null || spent === null || view.percent === null)
return estimate;
if (view.percent >= 100) return null;
return new Decimal(estimate)
.sub(spent)
.mul(100)
.div(100 - view.percent)
.toDecimalPlaces(12)
.toString();
}
5 changes: 3 additions & 2 deletions tests/view-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createDiagnosticsLogger,
type LogEvent,
} from "../src/server/diagnostics";
import { fullWeekEstimate } from "./quota-projection";

const query: ViewQuery = {
filter: { days: 7, model: "all", account: "all", search: "" },
Expand Down Expand Up @@ -1268,13 +1269,13 @@ test("worker keeps quota period and estimate amounts aligned with USD basis", as
);
expect(apiQuota.periodUsd).not.toBe(subscriptionQuota.periodUsd);
expect(apiQuota.periodCredits).toBe(subscriptionQuota.periodCredits);
expect(subscriptionQuota.estimate?.usd).toBe(
expect(fullWeekEstimate(subscriptionQuota)).toBe(
new Decimal(valuation.subscriptionUsd.amount!)
.mul(100)
.div(10)
.toString(),
);
expect(apiQuota.estimate?.usd).toBe(
expect(fullWeekEstimate(apiQuota)).toBe(
new Decimal(valuation.apiUsd.amount!).mul(100).div(10).toString(),
);
expect(subscriptionQuota.estimate?.reason).toBe("eligible");
Expand Down
Loading
0