8000 fix(quota): isolate estimates across plan changes for 0.9.4 by InfinityPacer · Pull Request #30 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

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

## [未发布]

## [0.9.4] - 2026-10-04

- 七天额度预估按已观测的套餐区间隔离历史,换套餐后不再沿用旧套餐额度;同一周期中途变更也会重新计算,累计用量保持完整。
- 套餐变化或百分比回退后,粗估使用新采样之间的消费与比例增量,避免混入变更前消费。新区间尚无可比较采样时暂时保留未知。
- 保存套餐观测历史,避免重复推送和带旧额度快照的晚到批次把套餐倒退。已有旧快照不补写当前套餐,套餐未知时不借用上一周期估值。

## [0.9.2] - 2026-10-04

- 仅接入本机采集器时,页面也会自动显示新数据,并显示最近更新时间;支持暂停页面自动更新,回到页面时立即检查变化。
Expand Down
4 changes: 3 additions & 1 deletion docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@

七天预估表示按实际观测推算的整周额度价值。系统比较当前周期内多个额度快照之间的费用与百分比变化,每段至少跨越 5 个百分点,积累至少 3 个有效点对后取中位数。同一百分比的重复采样不会增加权重。5 小时窗口不做此预估。

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

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

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

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.2",
"version": "0.9.4",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
15 changes: 15 additions & 0 deletions src/domain/quota-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** 服务器观察到的套餐边界;首次观察不证明该套餐在此前已经生效。 */
export interface QuotaPlanObservation {
plan: string | null;
observedAt: string;
/** 包括已知套餐与未知套餐之间的变化,旧账户的首次变化也属于边界。 */
changed: boolean;
}

/** 来源和账户键允许包含分隔符,数组序列化保留完整身份。 */
export function quotaPlanHistoryKey(
sourceId: string,
accountExternalId: string,
) {
return `quota:planHistory:${JSON.stringify([sourceId, accountExternalId])}`;
}
67 changes: 58 additions & 9 deletions src/domain/quota.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Decimal from "decimal.js";
import type { QuotaPlanObservation } from "./quota-plan";
import {
estimateQuotaSegments,
roughQuotaEstimate,
Expand All @@ -18,6 +19,7 @@ export interface PricedUsage {
export interface CollectedQuota {
fact: QuotaFact;
collectedAt: string;
planHistory?: readonly QuotaPlanObservation[];
}

/** modelScope 非空时只汇总满足条件的模型;读取器必须遵守,否则按模型计量的窗口会混入其他请求。 */
Expand Down Expand Up @@ -206,22 +208,47 @@ export function quotaView(
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 observations = sameSource.flatMap(({ fact: sample }) => {
// 已发现套餐变化后不能使用变化前的点。初次建立历史不冒充过去的套餐证明。
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 < cycleStart ||
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 baseline = rebased ? observations[0] : undefined;
const charges = new Map<string, QuotaEstimateAmounts>();
const read = (at: string) => {
let value = charges.get(at);
Expand All @@ -234,13 +261,30 @@ export function quotaView(
}
return value;
};
const segments = estimateQuotaSegments(observations, read);
// 消费和百分比同时平移,套餐中途变化或百分比回退时不混入旧区间消费。
const baselineCharges = baseline ? read(baseline.sampledAt) : null;
const estimateRead = (at: string): QuotaEstimateAmounts => {
const amount = read(at);
if (!baselineCharges) return amount;
const difference = (unit: "usd" | "credits") =>
amount[unit] === null || baselineCharges[unit] === null
? null
: new Decimal(amount[unit]!).sub(baselineCharges[unit]!).toString();
return { usd: difference("usd"), credits: difference("credits") };
};
const estimateObservations = baseline
? observations.map((entry) => ({
...entry,
percent: new Decimal(entry.percent).sub(baseline.percent).toNumber(),
}))
: observations;
const segments = estimateQuotaSegments(estimateObservations, estimateRead);
const rough = roughQuotaEstimate(
observations,
read,
estimateObservations,
estimateRead,
cycleReset === reset ? 0 : 10,
);
return { segments, rough, observations };
return { segments, rough, observations, rebased };
};
const current = estimateCycle(reset, sampled);
// 只借用紧邻的上一周期;缺少上一周期或长时间停用后,不沿用久远额度。
Expand All @@ -252,9 +296,14 @@ export function quotaView(
? candidate
: latestReset;
}, -Infinity);
const previous = Number.isFinite(previousReset)
? estimateCycle(previousReset, previousReset)
: null;
// 只有当前已知套餐区间内的历史才有资格参考,未知和旧版本快照不能证明套餐相同。
const previous =
plan?.plan &&
Number.isFinite(previousReset) &&
previousReset > planStart &&
!current.rebased
? estimateCycle(previousReset, previousReset)
: null;
const methods: NonNullable<QuotaView["estimate"]["methods"]> = {
usd: null,
credits: null,
Expand Down
2 changes: 1 addition & 1 deletion src/server/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export class SyncRunner {
try {
const accounts = await this.connector.readAccounts();
accountsCount = accounts.length;
this.store.saveAccountsSnapshot(sourceId, accounts);
this.store.saveAccountsSnapshot(sourceId, accounts, now);
this.store.setState(this.metadataSuccessKey("accounts"), now);
this.logStage("accounts", {
count: accountsCount,
Expand Down
4 changes: 2 additions & 2 deletions src/server/view-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@ export class ViewService {
let cache: ViewCache | null = null;
try {
const namespace = JSON.stringify({
// 多段额度预估不能恢复为旧版单点结果。仅失效派生缓存,不修改账本。
format: 2,
// 套餐边界参与预估,旧口径的派生结果不能恢复;原始账本保持完整。
format: 3,
source: sourceFileState(sourcePath).identity,
book: book.id,
});
Expand Down
144 changes: 120 additions & 24 deletions src/storage/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
ref,
type AccountMergeState,
} from "../domain/account-merge";
import {
quotaPlanHistoryKey,
type QuotaPlanObservation,
} from "../domain/quota-plan";

export interface StoredUsage {
fact: UsageFact;
Expand All @@ -50,6 +54,7 @@ export interface StoredUsageChangeStream extends Omit<
export interface StoredQuota {
fact: QuotaFact;
collectedAt: string;
planHistory?: readonly QuotaPlanObservation[];
}

/** 报表索引使用的稳定 primitive;金额继续以十进制字符串跨线程传递。 */
Expand Down Expand Up @@ -473,23 +478,60 @@ export class LedgerStore {
return changed;
}

saveAccounts(accounts: SourceAccount[]) {
/** 账户与套餐观测同事务写入;相同套餐不重复记录观测时间。 */
private writeAccount(account: SourceAccount, observedAt: string) {
const key = quotaPlanHistoryKey(account.sourceId, account.externalId);
const history = this.getState<QuotaPlanObservation[]>(key) ?? [];
const previousObservation = history.at(-1);
const previousRow = previousObservation
? null
: this.db
.query<{ payload: string }, [string, string]>(
"SELECT payload FROM accounts WHERE source_id=? AND external_id=?",
)
.get(account.sourceId, account.externalId);
const previousAccount = previousRow
? (JSON.parse(previousRow.payload) as SourceAccount)
: null;
const historyChanged =
!previousObservation || previousObservation.plan !== account.plan;
if (historyChanged) {
history.push({
plan: account.plan,
observedAt,
changed: previousObservation
? true
: previousAccount !== null && previousAccount.plan !== account.plan,
});
this.setState(key, history);
}
const write = this.db
.query(
"INSERT INTO accounts VALUES (?, ?, ?) ON CONFLICT(source_id, external_id) DO UPDATE SET payload=excluded.payload WHERE accounts.payload IS NOT excluded.payload",
)
.run(account.sourceId, account.externalId, JSON.stringify(account));
return historyChanged || write.changes > 0;
}

saveAccounts(
accounts: SourceAccount[],
observedAt = new Date().toISOString(),
) {
this.db.transaction(() => {
let changed = false;
for (const account of accounts) {
const write = this.db
.query(
"INSERT INTO accounts VALUES (?, ?, ?) ON CONFLICT(source_id, external_id) DO UPDATE SET payload=excluded.payload WHERE accounts.payload IS NOT excluded.payload",
)
.run(account.sourceId, account.externalId, JSON.stringify(account));
changed ||= write.changes > 0;
changed = this.writeAccount(account, observedAt) || changed;
}
if (changed) this.bumpRevision();
})();
}

/** 在单一事务内提交来源账户快照;空快照也会删除该来源全部账户。 */
saveAccountsSnapshot(sourceId: string, accounts: readonly SourceAccount[]) {
saveAccountsSnapshot(
sourceId: string,
accounts: readonly SourceAccount[],
observedAt = new Date().toISOString(),
) {
for (const account of accounts) {
if (account.sourceId !== sourceId)
throw new Error("Connector returned a foreign source account");
Expand All @@ -502,12 +544,7 @@ export class LedgerStore {
this.db.query("DELETE FROM account_snapshot_ids").run();
let changed = false;
for (const account of accounts) {
const write = this.db
.query(
"INSERT INTO accounts VALUES (?, ?, ?) ON CONFLICT(source_id, external_id) DO UPDATE SET payload=excluded.payload WHERE accounts.payload IS NOT excluded.payload",
)
.run(account.sourceId, account.externalId, JSON.stringify(account));
changed ||= write.changes > 0;
changed = this.writeAccount(account, observedAt) || changed;
this.db
.query("INSERT OR IGNORE INTO account_snapshot_ids VALUES (?)")
.run(account.externalId);
Expand Down Expand Up @@ -576,15 +613,29 @@ export class LedgerStore {
for (const item of [...batch.accounts, ...batch.quotas])
if (item.sourceId !== sourceId)
throw new Error("Ingest batch contains a foreign source item");
const nowMs = Date.parse(now);
const sampledByAccount = new Map<string, number>();
for (const quota of batch.quotas) {
const sampledMs = quota.sampledAt ? Date.parse(quota.sampledAt) : NaN;
if (!Number.isFinite(sampledMs) || sampledMs > nowMs) continue;
sampledByAccount.set(
quota.accountExternalId,
Math.max(
sampledByAccount.get(quota.accountExternalId) ?? -Infinity,
sampledMs,
),
);
}
this.db.transaction(() => {
let changed = false;
for (const account of batch.accounts) {
const write = this.db
.query(
"INSERT INTO accounts VALUES (?, ?, ?) ON CONFLICT(source_id, external_id) DO UPDATE SET payload=excluded.payload WHERE accounts.payload IS NOT excluded.payload",
)
.run(sourceId, account.externalId, JSON.stringify(account));
changed ||= write.changes > 0;
const sampledMs = sampledByAccount.get(account.externalId);
if (
sampledMs !== undefined &&
this.isStalePlanChange(account, sampledMs, nowMs)
)
continue;
changed = this.writeAccount(account, now) || changed;
}
changed = this.writeFacts(sourceId, batch.usage, now) || changed;
changed = this.writeQuotas(batch.quotas, now) || changed;
Expand All @@ -597,6 +648,42 @@ export class LedgerStore {
})();
}

/** 额度采样不得早于已观察的套餐边界或已有快照;未来日期不能充当水位。 */
private isStalePlanChange(
account: SourceAccount,
sampledMs: number,
nowMs: number,
) {
const priorRow = this.db
.query<{ payload: string }, [string, string]>(
"SELECT payload FROM accounts WHERE source_id=? AND external_id=?",
)
.get(account.sourceId, account.externalId);
if (
!priorRow ||
(JSON.parse(priorRow.payload) as SourceAccount).plan === account.plan
)
return false;
const latestObservation = this.getState<QuotaPlanObservation[]>(
quotaPlanHistoryKey(account.sourceId, account.externalId),
)?.at(-1);
const observedMs = latestObservation
? Date.parse(latestObservation.observedAt)
: NaN;
if (Number.isFinite(observedMs) && sampledMs <= observedMs) return true;
const rows = this.db
.query<{ sampled_at: string }, [string, string]>(
"SELECT sampled_at FROM quota_snapshots WHERE source_id=? AND account_id=? AND sampled_at IS NOT NULL",
)
.all(account.sourceId, account.externalId);
let latest = -Infinity;
for (const row of rows) {
const at = Date.parse(row.sampled_at);
if (Number.isFinite(at) && at <= nowMs) latest = Math.max(latest, at);
}
return sampledMs <= latest;
}

/** 数据版本和事实同事务提交;相同页面重放不使报表缓存失效。 */
revision(): number {
return this.getState<number>("ledger:dataRevision") ?? 0;
Expand Down Expand Up @@ -633,15 +720,24 @@ export class LedgerStore {
.map((row) => JSON.parse(row.payload) as SourceAccount);
}
quotas(): StoredQuota[] {
const histories = new Map<string, QuotaPlanObservation[] | null>();
return this.db
.query<{ payload: string; collected_at: string }, []>(
"SELECT payload, collected_at FROM quota_snapshots ORDER BY sampled_at, collected_at",
)
.all()
.map((row) => ({
fact: JSON.parse(row.payload) as QuotaFact,
collectedAt: row.collected_at,
}));
.map((row) => {
const fact = JSON.parse(row.payload) as QuotaFact;
const key = quotaPlanHistoryKey(fact.sourceId, fact.accountExternalId);
if (!histories.has(key))
histories.set(key, this.getState<QuotaPlanObservation[]>(key));
const planHistory = histories.get(key);
return {
fact,
collectedAt: row.collected_at,
...(planHistory ? { planHistory } : {}),
};
});
}

/** 返回已落盘的来源事实数量;不把额度快照或本地估值重复计入。 */
Expand Down
Loading
Loading
0