diff --git a/design/ui_kits/console/Sidebar.jsx b/design/ui_kits/console/Sidebar.jsx index a33e2a2..64fa0fe 100644 --- a/design/ui_kits/console/Sidebar.jsx +++ b/design/ui_kits/console/Sidebar.jsx @@ -258,16 +258,18 @@ function AgentCard({ global, summary, t, collapsed, projects }) { {t.gAgents.toUpperCase()} {t.apWeek} + {/* 口径说明:本卡仅统计 maestro 任务执行,与账号级额度条不同源 */} +
{t.apScopeMaestro}
{/* 概览 2×2 */}
{[ - [fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'], - ['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'], - [summary.runsWeek, t.apRuns, 'var(--ink)'], - [summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'], - ].map(([v, k, c], i) => ( + [fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)', null], + ['≈$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)', t.apCostEstimateHint], + [summary.runsWeek, t.apRuns, 'var(--ink)', null], + [summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)', null], + ].map(([v, k, c, hint], i) => (
-
{v}
+
{v}
{k}
))} @@ -319,6 +321,7 @@ function AgentDetailModal({ projects, t, onClose }) {
{t.apDetailTitle} + · {t.apScopeMaestro}
{[['day', t.apByDay], ['week', t.apByWeek], ['month', t.apByMonth]].map(([g, label]) => ( +
+ +

控制台里的「用量」其实来自两个互不相交的数据源,口径不同、覆盖范围不同,所以经常看着「对不上」。本文把两者来源、为什么对不上、成本怎么算、按天/周/月怎么分桶讲清楚。

+ + +
两个数据源 · TWO SOURCES
+ +
+
+

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 任务执行」。 +
+ + +
成本如何计算 · COST FORMULA
+ +

数据源 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() +
+ + + + + + + + + + + + + + + + +
模型inputoutputcacheReadcacheWrite
claude-opus-4-815751.518.75
claude-sonnet-4-63150.33.75
claude-haiku-4-5150.11.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)。
  • +
  • $ 为名义估算,非订阅实账单(见上)。
  • +
+ +
+ 单一事实源:成本计价集中于 src/model/pricing.ts,分桶 / 窗口集中于 src/store/store.ts
+ 数据源 A(额度):src/daemon/usage.ts;数据源 B(明细):cc.ts → ingest.ts → store.ts
+ 本文随实现变化更新;新增 / 迁移文档请同步登记 docs/index.html。 +
+ +
+ + 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() });