+
+
+
+
控制台里的「用量」其实来自两个互不相交的数据源,口径不同、覆盖范围不同,所以经常看着「对不上」。本文把两者来源、为什么对不上、成本怎么算、按天/周/月怎么分桶讲清楚。
+
+
+
+
+
+
+
A · 账号级订阅额度
+
额度条 / QuotaMeter(顶栏)
+
+ - daemon 直连
GET api.anthropic.com/api/oauth/usage,取 five_hour / seven_day 的利用率百分比。
+ - 覆盖范围 = 整个 Claude Code 账号:你手敲的交互会话、别的项目、别的工具,全部计入。
+ - maestro 只能拿到这个「总量百分比」,拿不到明细,也无法拆出「哪部分是 maestro 烧的」。
+ - 来源:src/daemon/usage.ts
+
+
+
+
B · maestro 自有 run 的 cost / token
+
AGENT 卡 2×2 + 用量详情弹框
+
+ - 每次 maestro worker 跑 CC 调用,从 result 读 四类 token 写进
outbox.ndjson,daemon 折算累加进 runs.cost_usd / runs.usage。
+ - 覆盖范围 = 仅 maestro 的任务执行(executor + 双复审 + 解冲突等),你自己的会话不在内。
+ - 天然比额度条少 —— 它只统计自己发起的 run。
+ - 来源:cc.ts → ingest.ts → store.setRunUsage() / costSummary() / usageDetail()
+
+
+
+
+
+ 「最近只有 maestro 在跑,却还有其他 token 消费」的真相:
+ 那是你自己用 Claude Code 烧的额度(交互会话 / 其他工具)。它只反映在数据源 A(账号级额度条)里,maestro 采集不到、也算不进数据源 B 的明细,但额度条照样会动。这不是 bug,是两个数据源口径不同。 控制台已分别标注:额度条标「账号级,含交互会话」,AGENT 卡 / 详情标「仅统计 maestro 任务执行」。
+
+
+
+
+
+
数据源 B 的成本是逐次 CC 调用精确折算后累加的。公式:
+
+
+ 成本(USD) = Σ( input·Pin + output·Pout + cacheRead·Pcr + cacheWrite·Pcw ) / 1,000,000
+ 四类 token 各按其模型单价折算;单价单位 = USD / 每 1M token。一个 run 可能含多次 CC 调用(executor + 双复审 / resume / 回退),每次按各自模型精确折算后求和 → runs.cost_usd 准确。来源:src/model/pricing.ts · store.setRunUsage()
+
+
+
+
+
+ | 模型 |
+ input |
+ output |
+ cacheRead |
+ cacheWrite |
+
+
+
+ | claude-opus-4-8 | 15 | 75 | 1.5 | 18.75 |
+ | claude-sonnet-4-6 | 3 | 15 | 0.3 | 3.75 |
+ | claude-haiku-4-5 | 1 | 5 | 0.1 | 1.25 |
+
+
+
单价单位:USD / 1M token。模型名归一:精确命中优先,否则按已知前缀匹配(容忍日期 / 区域后缀,如 claude-opus-4-8-20260101)。源:src/model/pricing.ts · MODEL_PRICES
+
+
+ $ 是按 API 列表价的名义估算,不是订阅实际扣费。 它衡量「这些 run 若按 API 计价值多少钱」,便于横向比较项目/模型开销;你的实际账单取决于订阅套餐,二者不应直接相等。控制台 $ 处标注「按 API 列表价估算,非订阅实际扣费」,并以 ≈ 前缀提示。
+
+
+
+
按天 / 周 / 月分桶与时区 · BUCKETING
+
+
+ - 按天:近 30 天,桶 = 本地
YYYY-MM-DD。
+ - 按周:近 12 周,桶 = 本地周一
YYYY-MM-DD(滚动 7 天窗)。
+ - 按月:近 12 个月,桶 = 本地
YYYY-MM。
+ - 「今天 / 本月」窗口起点(
periodStartISO)、预算护栏窗口同样以本地零点为基准。
+
+
+
+ 本次修复(时区分桶): 此前分桶与窗口起点全用 UTC,UTC+8 用户在本地 00:00–08:00 跑的 run 会被算进前一天、当天窗口晚 8 小时开始 → 柱状图整体偏移一天、当天偏小。现已统一改为 daemon 本地时区(local-first 单机,daemon 时区即用户时区,零配置)。SQL 比较的是绝对时刻,本地零点转 ISO 后比较仍正确。源:store.ts · localDay / localMonth / periodStartISO / weekStartLabel / usageDetail
+
+
+
+
已知近似 · KNOWN APPROXIMATIONS
+
+
+ - 分模型 token 为近似。
setRunUsage 把累计 token 连同最后一次 CC 调用的 model 一起存({...usage, model});总 cost_usd 逐次精确折算 → 总花费准,但 byModel 的 token / cost 拆分按「run 末次模型」归集,不精确。控制台模型列以 ≈ 角标提示。
+ - 未知模型记 0。 若配置了非
opus-4-8 / sonnet-4-6 / haiku-4-5 前缀的模型,priceOf 命中失败 → computeCost 返回 0(不阻断执行,仅成本记 0)。
+ - $ 为名义估算,非订阅实账单(见上)。
+
+
+
+
+
+
+
diff --git a/src/store/store.ts b/src/store/store.ts
index 014339c..a49d153 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -42,21 +42,34 @@ const SETTINGS_DEFAULT: UserSettings = {
};
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
-/** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */
+const pad2 = (n: number): string => String(n).padStart(2, '0');
+/** 按 daemon 本地时区把日期格式化为 YYYY-MM-DD(用作「按天」时间桶标签)。 */
+function localDay(d: Date): string {
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+/** 按 daemon 本地时区把日期格式化为 YYYY-MM(用作「按月」时间桶标签)。 */
+function localMonth(d: Date): string {
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
+}
+
+/**
+ * 预算周期起点(daemon 本地时区):day=今日本地零点 / month=当月 1 号本地零点。
+ * 本地零点经 toISOString() 转回绝对时刻,与存储的 ISO 时间戳同基准比较仍正确。
+ */
function periodStartISO(period: 'day' | 'week' | 'month'): string {
const d = new Date();
if (period === 'week') return new Date(Date.now() - 7 * 86400_000).toISOString();
const start = period === 'day'
- ? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
- : Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
- return new Date(start).toISOString();
+ ? new Date(d.getFullYear(), d.getMonth(), d.getDate())
+ : new Date(d.getFullYear(), d.getMonth(), 1);
+ return start.toISOString();
}
-/** 把 ISO 时间戳归一到所属周的周一(UTC),返回 YYYY-MM-DD,作为「按周」时间桶标签。 */
+/** 把 ISO 时间戳归一到所属周的周一(daemon 本地时区),返回本地 YYYY-MM-DD,作为「按周」时间桶标签。 */
function weekStartLabel(iso: string): string {
const d = new Date(iso);
- const dow = (d.getUTCDay() + 6) % 7; // 周一=0 … 周日=6
- return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - dow)).toISOString().slice(0, 10);
+ const dow = (d.getDay() + 6) % 7; // 周一=0 … 周日=6
+ return localDay(new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow));
}
export class StoreError extends Error {}
@@ -1248,8 +1261,8 @@ export class Store {
}
/**
- * 用量详情:时间序列(按天/按周/按月分桶)+ 分项目下钻(token/花费/任务数/模型)。
- * day → 近 30 天、桶=YYYY-MM-DD;week → 近 12 周、桶=周一 YYYY-MM-DD;month → 近 12 个月、桶=YYYY-MM。
+ * 用量详情:时间序列(按天/按周/按月分桶,均按 daemon 本地时区)+ 分项目下钻(token/花费/任务数/模型)。
+ * day → 近 30 天、桶=本地 YYYY-MM-DD;week → 近 12 周、桶=本地周一 YYYY-MM-DD;month → 近 12 个月、桶=本地 YYYY-MM。
* token/model 解析与 costSummary 同款(usage JSON 存累计 token + 最近 model,model 维度为近似)。
*/
usageDetail(granularity: 'day' | 'week' | 'month'): UsageDetail {
@@ -1258,7 +1271,7 @@ export class Store {
? new Date(Date.now() - 30 * 86400_000).toISOString()
: granularity === 'week'
? new Date(Date.now() - 12 * 7 * 86400_000).toISOString()
- : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)).toISOString();
+ : new Date(now.getFullYear(), now.getMonth() - 11, 1).toISOString();
const rows = this.db.prepare(
`SELECT t.project_id AS pid, r.task_id AS tid, r.started_at AS at, r.usage AS usage, r.cost_usd AS cost
FROM runs r JOIN tasks t ON t.id = r.task_id
@@ -1273,9 +1286,9 @@ export class Store {
const u = row.usage ? JSON.parse(row.usage) as UsageTokens & { model?: string } : null;
const tokens = u ? u.input + u.output + u.cacheRead + u.cacheWrite : 0;
const model = u?.model ?? 'unknown';
- const bucket = granularity === 'day' ? row.at.slice(0, 10)
+ const bucket = granularity === 'day' ? localDay(new Date(row.at))
: granularity === 'week' ? weekStartLabel(row.at)
- : row.at.slice(0, 7);
+ : localMonth(new Date(row.at));
const s = series.get(bucket) ?? { cost: 0, tokens: 0, runs: 0 };
s.cost += row.cost; s.tokens += tokens; s.runs += 1; series.set(bucket, s);
diff --git a/test/budget.test.ts b/test/budget.test.ts
index 987c116..cf0a4c9 100644
--- a/test/budget.test.ts
+++ b/test/budget.test.ts
@@ -42,6 +42,46 @@ test('store: setRunUsage 累加成本 + costSummary 聚合', () => {
s.close();
});
+test('store: usageDetail/costSummary 按 daemon 本地时区分桶(UTC+8 跨日不再偏移一天)', () => {
+ const origTZ = process.env.TZ;
+ process.env.TZ = 'Asia/Shanghai'; // 固定 UTC+8,制造 UTC 日 != 本地日
+ try {
+ const s = new Store(':memory:');
+ const p = s.createProject({ name: 'tz', repoPath: '/tmp/tz-' + Math.random() });
+ const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
+
+ // —— 按天分桶:近 2 天内 UTC 18:00 的 run → 本地为次日 02:00,本地日应比 UTC 日晚一天 ——
+ const r1 = s.startRun(t.id, 'executor');
+ s.setRunUsage(r1.id, { input: 1_000_000, output: 0, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8');
+ const base = new Date();
+ base.setUTCDate(base.getUTCDate() - 2);
+ base.setUTCHours(18, 0, 0, 0);
+ const startedAt = base.toISOString();
+ s.db.prepare('UPDATE runs SET started_at = ? WHERE id = ?').run(startedAt, r1.id);
+
+ const utcDay = startedAt.slice(0, 10);
+ const ld = new Date(startedAt);
+ const localDay = `${ld.getFullYear()}-${String(ld.getMonth() + 1).padStart(2, '0')}-${String(ld.getDate()).padStart(2, '0')}`;
+ assert.notEqual(localDay, utcDay, 'UTC+8 下 18:00Z 应跨到本地次日,前提才成立');
+
+ const detail = s.usageDetail('day');
+ assert.ok(detail.series.some((b) => b.bucket === localDay), `应分桶到本地日 ${localDay}`);
+ assert.equal(detail.series.some((b) => b.bucket === utcDay), false, `不应再用 UTC 日 ${utcDay}`);
+
+ // —— 当天窗口:本地今天 02:00(UTC 仍为昨天 18:00)的 run 应计入「今天」 ——
+ const r2 = s.startRun(t.id, 'executor');
+ s.setRunUsage(r2.id, { input: 0, output: 1_000_000, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8'); // $75
+ const today2am = new Date();
+ today2am.setHours(2, 0, 0, 0); // 本地今天 02:00
+ s.db.prepare('UPDATE runs SET started_at = ? WHERE id = ?').run(today2am.toISOString(), r2.id);
+ assert.ok(s.costSummary('day').total >= 75, '本地 00:00–08:00 的 run 应计入今天(旧 UTC 口径会漏算)');
+
+ s.close();
+ } finally {
+ if (origTZ === undefined) delete process.env.TZ; else process.env.TZ = origTZ;
+ }
+});
+
test('store: enforceBudget 超额 → 暂停项目 + 广播 budget.exceeded', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'b', repoPath: '/tmp/b-' + Math.random() });