8000 fix(quota): reconcile Go plan limits by request count for 0.11.2 by InfinityPacer · Pull Request #47 · 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
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.11.2] - 2026-10-23

- OpenCode Go 的套餐上限预估只在本周期每次请求都来自已登记上限的模型时使用。0.11.1 按金额核对,没有价格的模型(例如映射到 Go 账户的 Gemini)不计入金额,会让页面误把 DeepSeek 的上限当成整窗价值。本周期还没有请求时也不再假定所用模型。
- 只需升级服务端镜像,数据库结构未变,升级无需先备份。

## [0.11.1] - 2026-10-23

- 通过 Paseo 使用 Claude Code 时额度也能保持更新。Paseo 不运行状态栏命令,新增的 `scripts/paseo-claude-quota.sh` 作为 Paseo 的 Claude 启动命令,从 Claude Code 的输出流里抄下 5 小时与 7 天额度,写进采集器读取的状态栏额度缓存,不读凭据、不发额外请求,用法见[本机采集器](docs/collector.md#通过-paseo-使用-claude-code)。
Expand Down
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ METERLEAF_OPENCODE_GO_KEY=<OpenCode Go 的 API Key>

服务端只在有页面打开时读取额度,页面在线期间最多每 2 分钟读一次,没人看时不访问 Go。读取失败只在日志里记 `opencode_go.quota_failed` 和状态码,不记录 Key;Key 失效或被拒时页面保留上次读到的额度,直到它的周期结束。页面在周额度之后显示「1m」窗口。

Go 的额度按美元计,而且每个模型各有月上限,Go Plus 的上限更高,具体见 [OpenCode Go 文档](https://opencode.ai/docs/go/)。5 小时、每周、每月三个窗口分别是月上限的 20%、50% 和 100%,请求按该模型的 Token 单价计费,再折算成所在窗口的百分比。以 DeepSeek V4.1 Flash 在 Go 档为例,月上限 80 美元,每周 40 美元,5 小时 16 美元。5 小时窗口滚动计算,周窗口在每周一 00:00 UTC 重置,月窗口从订阅日起算。在采集器 `modelAccounts` 里设了 `plan`(`go` 或 `go-plus`)时,「7d 预估」和「1m 预估」直接显示套餐上限(按本周期各模型的花费比例折算),不再外推。本周期用了上限表里没有的模型或没设档位时,仍按百分比外推,周期初期误差较大。
Go 的额度按美元计,而且每个模型各有月上限,Go Plus 的上限更高,具体见 [OpenCode Go 文档](https://opencode.ai/docs/go/)。5 小时、每周、每月三个窗口分别是月上限的 20%、50% 和 100%,请求按该模型的 Token 单价计费,再折算成所在窗口的百分比。以 DeepSeek V4.1 Flash 在 Go 档为例,月上限 80 美元,每周 40 美元,5 小时 16 美元。5 小时窗口滚动计算,周窗口在每周一 00:00 UTC 重置,月窗口从订阅日起算。在采集器 `modelAccounts` 里设了 `plan`(`go` 或 `go-plus`)时,「7d 预估」和「1m 预估」直接显示套餐上限(按本周期各模型的花费比例折算),不再外推。上限表目前只有 DeepSeek V4.1 Flash。本周期只要有一次请求来自表外的模型(包括没有价格的模型),或者还没有任何请求,或者没设档位,仍按百分比外推,周期初期误差较大。

## GitHub 登录

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.11.1",
"version": "0.11.2",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
27 changes: 15 additions & 12 deletions src/domain/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,41 +170,44 @@ function rowsChargeReader(rows: readonly PricedUsage[]): QuotaChargeReader {

/**
* 档位已知时用公布的模型上限反推整窗价值,不再按取整后的百分比外推。
* 本周期有上限表之外的模型花费、或费用缺少可计价部分时返回 null,调用方沿用旧逻辑。
* 只有本周期每一次请求都来自上限表里的模型时才成立,按请求数对账而不是按金额:
* 缺价请求不计入金额,按金额对账会漏掉它们,把表内模型的上限当成整窗价值。
* 本周期没有请求时也无从知道将用哪个模型,同样返回 null,调用方沿用旧逻辑。
*/
function planLimitUsd(
budget: GoPlanBudget,
window: GoPlanWindow,
startsAt: string,
now: string,
readSource: QuotaChargeReader,
periodUsd: string | null,
periodRequests: number | null,
): string | null {
if (periodUsd === null) return null;
if (!periodRequests) return null;
const share = new Decimal(budget.windowShare[window]);
const charges: { cap: Decimal; spend: Decimal }[] = [];
let tracked = 0;
for (const limit of budget.models) {
const { usd } = readSource(startsAt, now, limit.scope);
const { usd, count } = readSource(startsAt, now, limit.scope);
if (count === undefined) return null;
if (count === 0) continue;
if (usd === null) return null;
tracked += count;
charges.push({
cap: new Decimal(limit.monthlyUsd).mul(share),
spend: new Decimal(usd),
});
}
const spent = new Decimal(periodUsd);
const tracked = charges.reduce(
// 表外模型(包括缺价模型)的上限未知,混入后无法按模型构成折算。
if (tracked !== periodRequests) return null;
const spent = charges.reduce(
(total, { spend }) => total.add(spend),
new Decimal(0),
);
// 表外模型的上限未知,混入后无法按模型构成折算,整窗价值也没有公布依据。
if (spent.sub(tracked).gt(0)) return null;
if (spent.eq(0))
return charges.length === 1 ? charges[0]!.cap.toString() : null;
const fraction = charges.reduce(
(total, { cap, spend }) => total.add(spend.div(cap)),
new Decimal(0),
);
return fraction.eq(0) ? null : spent.div(fraction).toString();
return fraction.gt(0) ? spent.div(fraction).toString() : null;
}

/** 百分比为整个上游账号,费用只覆盖本连接器;推算是条件性观测值而非订阅承诺额度。 */
Expand Down Expand Up @@ -373,7 +376,7 @@ export function quotaView(
startsAt!,
now,
readSource,
result.periodUsd,
result.periodRequests,
);
if (quota !== null) {
result.estimate.usd = quota;
Expand Down
57 changes: 44 additions & 13 deletions tests/quota-go-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,29 @@ function sample(
};
}

/** 按模型汇总本周期费用,scope 为空时返回账户全部模型。 */
/**
* 按模型汇总本周期费用,每个模型记一次请求;金额为 null 表示该模型缺价,
* 与索引一致,缺价请求计入次数但不计入金额。scope 为空时返回账户全部模型。
*/
const spends =
(byModel: Record<string, string>): QuotaChargeReader =>
(_start, _end, scope) => ({
usd: Object.entries(byModel)
.filter(([model]) => !scope || scope(model))
.reduce((total, [, usd]) => total.add(usd), new Decimal(0))
.toString(),
credits: null,
});
(byModel: Record<string, string | null>): QuotaChargeReader =>
(_start, _end, scope) => {
const rows = Object.entries(byModel).filter(
([model]) => !scope || scope(model),
);
const priced = rows.filter(([, usd]) => usd !== null);
return {
usd: priced.length
? priced
.reduce((total, [, usd]) => total.add(usd!), new Decimal(0))
.toString()
: rows.length
? null
: "0",
credits: null,
count: rows.length,
};
};

test("go weekly cap replaces the percent extrapolation with the published limit", () => {
const result = quotaView(
Expand Down Expand Up @@ -69,15 +82,33 @@ test("go-plus monthly cap is the published monthly limit", () => {
expect(result.estimate.methods?.usd).toBe("plan-limit");
});

test("a fresh window without spend still reports the published weekly limit", () => {
test("a window without requests does not assume which model will be used", () => {
const history = [sample("seven-day", 0)];
const result = quotaView(
[sample("seven-day", 0)],
history,
spends({}),
now,
goPlanBudget("opencode-go", "go"),
)!;
expect(result.estimate.usd).toBe("40");
expect(result.estimate.methods?.usd).toBe("plan-limit");
expect(result.estimate.methods?.usd).not.toBe("plan-limit");
expect(result).toEqual(quotaView(history, spends({}), now)!);
});

test("unpriced requests from another model keep the extrapolated estimate", () => {
// 缺价模型不计入金额,按金额对账会误以为本周期只用了表内模型。
const history = [sample("seven-day", 2)];
const reader = spends({
"deepseek-v4.1-flash": "0.44",
"gemini-4-argon": null,
});
const result = quotaView(
history,
reader,
now,
goPlanBudget("opencode-go", "go"),
)!;
expect(result.estimate.methods?.usd).not.toBe("plan-limit");
expect(result).toEqual(quotaView(history, reader, now)!);
});

test("spend from a model outside the plan table falls back to the old estimate", () => {
Expand Down
Loading
0