8000 feat(quota): show per-window cache hit rate on account cards for 0.12.2 by InfinityPacer · Pull Request #50 · 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.12.2] - 2026-10-24

- 账户额度卡片的每个窗口新增本周期缓存命中率。桌面端显示在「已用」下方,手机端跟在窗口标题行的已用比例后,例如「5h · 已用 58% · 命中 98.7%」。口径与统计页的缓存命中率相同,只统计缓存读取、缓存写入和普通输入都已知的请求。新周期还没有请求或窗口等待更新时不显示,不会显示成 0%。
- 只需升级服务端镜像,本机采集器不用替换。数据库结构未变,升级无需先备份。

## [0.12.1] - 2026-10-23

- 镜像与构建改用 Bun 1.4.4,包含其 fetch 与 TLS 的安全修复。从源码构建采集器同样需要 Bun 1.4.4 或更新版本。
Expand Down
2 changes: 1 addition & 1 deletion docs/reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
| 统计报表 | 范围摘要、模型分布与小时、天、周、模型、账户分组汇总 | 按本页日期和筛选条件 |
| 请求明细 | 来源、模型、推理强度、账户、Token 数和费用;点击查看完整证据 | 按本页条件筛选并在服务端分页 |

总览的账户区列出每个账户的周期百分比、重置时间、Tokens、请求数、估算费用与七天预估。无额度快照的账户优先展示历史累计,缺少累计时才使用当前区间汇总。点击有额度的账户打开额度详情,点击其它账户打开它的请求明细。API 接入只表示接入方式;有快照时仍可展示订阅额度。
总览的账户区列出每个账户的周期百分比、重置时间、Tokens、请求数、估算费用与七天预估,并附本周期的缓存命中率。命中率与统计报表同一口径,即缓存读取占输入、缓存读取、缓存写入三项之和的比例,只计三项都已知的请求;周期内还没有这类请求时不显示。无额度快照的账户优先展示历史累计,缺少累计时才使用当前区间汇总。点击有额度的账户打开额度详情,点击其它账户打开它的请求明细。API 接入只表示接入方式;有快照时仍可展示订阅额度。

总览只按时间切换,不按模型或账户筛选。第一眼是总量和账户额度,默认范围「历史至今」从账本第一条记录起算。换成今天、近 7 天、近 30 天或自定义日期后,摘要、趋势和模型分布一起变化,账户额度仍按各自当前周期显示,不受范围影响。

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.12.1",
"version": "0.12.2",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
15 changes: 14 additions & 1 deletion src/domain/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export type QuotaChargeReader = (
credits: string | null;
count?: number;
tokens?: number | null;
/** 缓存读取占输入侧三个桶的百分比,只统计三个桶都已知的请求;没有这类请求时为 null。 */
cacheRate?: number | null;
};

/**
Expand Down Expand Up @@ -62,6 +64,7 @@ export interface QuotaView {
/** 当前窗口起点至查询时刻的请求数与不重复 token 桶;到期后清空。 */
periodRequests: number | null;
periodTokens: number | null;
periodCacheRate: number | null;
estimate: {
usd: string | null;
credits: string | null;
Expand Down Expand Up @@ -145,6 +148,14 @@ function rowsChargeReader(rows: readonly PricedUsage[]): QuotaChargeReader {
(!modelScope || modelScope(row.fact.model))
);
});
let cacheInput = 0;
let cacheRead = 0;
for (const { fact } of selected) {
const { input, cacheRead: read, cacheWrite } = fact.tokens;
if (input === null || read === null || cacheWrite === null) continue;
cacheInput += input + read + cacheWrite;
cacheRead += read;
}
return {
usd: sumCharges(selected, "usd"),
credits: sumCharges(selected, "credits"),
Expand All @@ -164,6 +175,7 @@ function rowsChargeReader(rows: readonly PricedUsage[]): QuotaChargeReader {
(row.fact.tokens.output ?? 0),
0,
),
cacheRate: cacheInput ? (cacheRead / cacheInput) * 100 : null,
};
};
}
Expand Down Expand Up @@ -253,7 +265,7 @@ export function quotaView(
known && !expired
? start <= time
? readCharges(startsAt!, now)
: { usd: "0", credits: "0", count: 0, tokens: 0 }
: { usd: "0", credits: "0", count: 0, tokens: 0, cacheRate: null }
: null;
// 合并账户可能同时持有多个采样来源,只用最新来源比较百分比。
const sameSource = ordered
Expand Down Expand Up @@ -346,6 +358,7 @@ export function quotaView(
periodCredits: periodCharges?.credits ?? null,
periodRequests: periodCharges?.count ?? null,
periodTokens: periodCharges?.tokens ?? null,
periodCacheRate: periodCharges?.cacheRate ?? null,
estimate: {
usd: null,
credits: null,
Expand Down
1 change: 1 addition & 0 deletions src/server/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ export function indexedSnapshot(
credits: string | null;
count?: number;
tokens?: number | null;
cacheRate?: number | null;
},
observedAccountIds: string[] = [],
): Omit<LedgerSnapshot, "records"> {
Expand Down
1 change: 1 addition & 0 deletions src/server/view-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ function expireWindow(window: AccountWindow | null, now: number) {
periodCredits: null,
periodRequests: null,
periodTokens: null,
periodCacheRate: null,
estimate,
...(typeof window === "object" && "startsAt" in window
? { startsAt: null }
Expand Down
2 changes: 2 additions & 0 deletions src/shared/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export interface AccountWindow {
/** 当前额度周期的用量,与页面时间筛选和全历史累计相互独立。 */
periodRequests?: number | null;
periodTokens?: number | null;
/** 当前额度周期的缓存命中率(百分比),口径与报表汇总的命中率一致。 */
periodCacheRate?: number | null;
estimate?: import("../domain/quota").QuotaView["estimate"];
}

Expand Down
7 changes: 6 additions & 1 deletion src/storage/report-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,15 @@ function addAmount(
}

function windowSum(aggregate: Aggregate, usdBasis: UsdBasis) {
if (!aggregate.count) return { count: 0, tokens: 0, usd: "0", credits: "0" };
if (!aggregate.count)
return { count: 0, tokens: 0, usd: "0", credits: "0", cacheRate: null };
const usd = summaryAmount(aggregate, usdBasis);
return {
count: aggregate.count,
tokens: aggregate.tokenIncompleteRows === 0 ? aggregate.tokenValue : null,
cacheRate: aggregate.completeCacheInput
? (aggregate.completeCacheRead / aggregate.completeCacheInput) * 100
: null,
// 与明细路径一致:缺价记录不抹除同一周期内已经计价的金额。
usd: usd.knownRows > 0 ? usd.value.toString() : null,
credits:
Expand Down Expand Up @@ -1052,6 +1056,7 @@ export class ReportIndex {
tokens: number | null;
usd: string | null;
credits: string | null;
cacheRate: number | null;
} {
return windowSum(
this.windowAggregate(accountId, startInclusive, endInclusive, modelScope),
Expand Down
17 changes: 17 additions & 0 deletions src/web/components/QuotaWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function formatRequests(value: number | null | undefined) {

/**
* 首页卡片、账户列表共用的额度窗口:标题行、进度条、费用行,周额度在费用后附整周预估。
* 标题行在已用比例后附本周期缓存命中率。
* 每行内容固定,窗口数量变化只增减行或列,不需要为某个数量单独写布局。
* 整体用 span 渲染,可以放进可点击的账户按钮内。
*/
Expand Down Expand Up @@ -109,6 +110,14 @@ export function QuotaWindow({
showQuotaEstimate(window, asOf) &&
estimateText !== "N/A";
const status = usable ? quotaLabel(window, asOf) : "N/A";
// 命中率没有分母时不补 0%,整项不显示;口径与统计页的缓存命中率相同。
const cacheRate =
usable &&
window.periodCacheRate !== null &&
window.periodCacheRate !== undefined &&
Number.isFinite(window.periodCacheRate)
? window.periodCacheRate
: null;

return (
<span
Expand All @@ -120,6 +129,14 @@ export function QuotaWindow({
<span className="quota-window-title">
<strong className="quota-window-label">{label}</strong>
<span className="quota-window-status">{status}</span>
{cacheRate !== null && (
<span
className="quota-window-cache"
title="本周期缓存命中率,即缓存读取占输入的比例"
>
命中 {cacheRate.toFixed(1)}%
</span>
)}
</span>
<span className="quota-window-reset">
{formatResetTime(window.resetsAt, asOf)}
Expand Down
14 changes: 14 additions & 0 deletions src/web/components/quota-window.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@
font-weight: 400;
}

/* 命中率是已用比例的附注,同色同号;放不下时整项换到下一行,不拆开数字。 */
.quota-window-cache {
color: var(--muted);
font-size: 12px;
line-height: 1.35;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}

.quota-window-cache::before {
content: "·";
margin-right: 7px;
}

.quota-window[data-exhausted="true"] .quota-window-status {
color: var(--quota-alert);
}
Expand Down
7 changes: 7 additions & 0 deletions src/web/demo/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ export function createDemoLedger(usdBasis: UsdBasis = "subscription"): LedgerSna
(sum, row) => sum.add(row.credits ?? 0),
new Decimal(0),
);
const cacheInput = rows.reduce(
(sum, row) =>
sum + (row.input ?? 0) + (row.cacheRead ?? 0) + (row.cacheWrite ?? 0),
0,
);
const cacheRead = rows.reduce((sum, row) => sum + (row.cacheRead ?? 0), 0);
const project = (value: Decimal.Value) =>
new Decimal(value).mul(100).div(percent).toFixed(6);
return {
Expand All @@ -195,6 +201,7 @@ export function createDemoLedger(usdBasis: UsdBasis = "subscription"): LedgerSna
periodCredits: hasCredits ? credits.toFixed(6) : null,
periodRequests: period.count,
periodTokens: period.tokens,
periodCacheRate: cacheInput ? (cacheRead / cacheInput) * 100 : null,
...(hours === 168
? {
estimate: {
Expand Down
13 changes: 13 additions & 0 deletions src/web/desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,19 @@
text-align: right;
white-space: nowrap;
}
/* 有命中率时比例列分两行:已用比例对齐窗口名,命中率对齐重置时间与用量。 */
.app-shell .account-windows .quota-window:has(.quota-window-cache) {
grid-template-areas:
"label bar status amount"
"reset bar cache volume";
}
.app-shell .account-windows .quota-window-cache {
grid-area: cache;
text-align: right;
}
.app-shell .account-windows .quota-window-cache::before {
content: none;
}
.app-shell .account-windows .quota-window-amount {
grid-area: amount;
justify-self: end;
Expand Down
78 changes: 78 additions & 0 deletions tests/quota-cache-rate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, test } from "bun:test";
import type { UsageFact } from "../src/domain/connector";
import { quotaView, type PricedUsage } from "../src/domain/quota";
import type { Valuation } from "../src/domain/pricing";

const now = "2026-10-20T12:00:00.000Z";

function usage(
id: string,
tokens: Pick<UsageFact["tokens"], "input" | "cacheRead" | "cacheWrite">,
): PricedUsage {
return {
fact: {
sourceId: "test",
externalId: id,
accountExternalId: "account",
occurredAt: "2026-10-20T10:00:00.000Z",
model: "claude-opus-5-5",
upstreamModel: null,
tier: "standard",
tokens: {
...tokens,
output: 10,
cacheWrite5m: null,
cacheWrite1h: null,
reasoning: null,
},
gatewayCost: null,
gatewayBilled: null,
upstreamUsd: null,
upstreamCredits: null,
metadata: {},
},
valuation: {
usd: { amount: null },
credits: { amount: null },
} as unknown as Valuation,
};
}

const history = [
{
collectedAt: now,
fact: {
sourceId: "test",
externalId: "five-hour",
accountExternalId: "account",
window: "five-hour" as const,
percent: 20,
sampledAt: now,
resetsAt: "2026-10-20T14:00:00.000Z",
windowMinutes: 300,
},
},
];

test("quota cache rate counts only requests whose three input buckets are known", () => {
const view = quotaView(
history,
[
usage("complete", { input: 10, cacheRead: 90, cacheWrite: 0 }),
// 缓存写入未知的请求不进分子也不进分母,不能把未知当作 0 拉低命中率。
usage("partial", { input: 1000, cacheRead: 0, cacheWrite: null }),
],
now,
);
expect(view!.periodCacheRate).toBe(90);
expect(view!.periodRequests).toBe(2);
});

test("quota cache rate stays unknown when no request has complete input buckets", () => {
const view = quotaView(
history,
[usage("partial", { input: null, cacheRead: 50, cacheWrite: 0 })],
now,
);
expect(view!.periodCacheRate).toBeNull();
});
Loading
Loading
0