Files
maestro/docs/usage-cost-explained.html
wangjia d0aa53c79d fix(usage): 修按天分桶时区错位 + 区分账号额度/maestro用量口径 + 成本说明文档 (tsk_JKAzHuXvSw7h)
全局面板「用量」数据问题修复与澄清:

1. 时区分桶根因(store.ts):按天/周/月分桶与今天/本月窗口起点原全用 UTC,
   UTC+8 用户本地 00:00–08:00 的 run 被算进前一天、当天窗口晚 8h → 柱状图偏移
   一天、当天偏小。新增 localDay/localMonth,periodStartISO/weekStartLabel/
   usageDetail 改用 daemon 本地时区(SQL 比绝对时刻仍正确)。

2. UI 口径区分(design/ui_kits/console):额度条标注账号级含交互会话,AGENT 卡/
   详情弹框标注仅统计 maestro 任务执行,$ 加 API 列表价估算非实际扣费提示并以 ≈
   前缀,模型列加近似角标。新增 4 个 i18n key×5 语言。

3. 文档:新增 docs/usage-cost-explained.html(两数据源/为何对不上/cost 公式/
   单价表/本地时区分桶/已知近似),登记进 docs/index.html 知识库调研。

4. 测试:budget.test.ts 新增 UTC+8 跨日分桶用例。typecheck + 268 测试全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:18:36 +08:00

228 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MAESTRO · 用量与成本计算说明</title>
<style>
:root {
--bg: #0a0d0b; --bg-deep: #070908; --panel: #111610; --panel-2: #161c13;
--line: #1e2a1c; --ink: #c8d4c0; --muted: #7a9070; --faint: #445040;
--green: #4ade80; --green-dim: rgba(74,222,128,.12);
--violet: #a78bfa; --violet-dim: rgba(167,139,250,.1);
--amber: #fbbf24; --amber-dim: rgba(251,191,36,.1);
--red: #f87171; --red-dim: rgba(248,113,113,.1);
--cyan: #22d3ee; --cyan-dim: rgba(34,211,238,.1);
--mono: 'IBM Plex Mono', 'Noto Sans SC', monospace;
--radius-sm: 4px; --radius-md: 6px;
}
[data-theme="light"] {
--bg: #f4f6f2; --bg-deep: #eaede7; --panel: #ffffff; --panel-2: #f0f2ee;
--line: #d0d8cc; --ink: #1a2418; --muted: #4a6045; --faint: #8a9e86;
--green: #16a34a; --green-dim: rgba(22,163,74,.1);
--violet: #7c3aed; --violet-dim: rgba(124,58,237,.1);
--amber: #d97706; --amber-dim: rgba(217,119,6,.1);
--red: #dc2626; --red-dim: rgba(220,38,38,.1);
--cyan: #0891b2; --cyan-dim: rgba(8,145,178,.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--ink);
font-family: var(--mono); font-size: 13px; line-height: 1.7; padding: 32px;
}
body::before {
content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,.14) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at 50% 40%, transparent 55%, rgba(0,0,0,.45));
opacity: .45;
}
[data-theme="light"] body::before { opacity: .07; }
.page { position: relative; z-index: 1; max-width: 1000px; margin: 0 auto; }
.header {
display: flex; align-items: baseline; gap: 16px;
border-bottom: 1px solid var(--line); padding-bottom: 20px; margin-bottom: 28px;
}
.logo { font-size: 18px; font-weight: 700; letter-spacing: .25em; color: var(--green); text-shadow: 0 0 16px var(--green); }
.logo span { animation: blink 1.1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.subtitle { color: var(--muted); font-size: 11px; letter-spacing: .2em; }
.theme-btn {
margin-left: auto; background: var(--panel); border: 1px solid var(--line);
color: var(--muted); font-family: var(--mono); font-size: 11px;
padding: 4px 10px; border-radius: var(--radius-sm); cursor: pointer; letter-spacing: .1em;
}
.theme-btn:hover { color: var(--ink); border-color: var(--muted); }
.lead { color: var(--muted); font-size: 13px; line-height: 1.9; margin-bottom: 8px; }
.lead strong { color: var(--ink); }
.sec-head {
display: flex; align-items: center; gap: 8px;
font-size: 10px; font-weight: 600; letter-spacing: .28em;
color: var(--muted); text-transform: uppercase;
margin-bottom: 16px; margin-top: 40px;
}
.sec-mark { width: 3px; height: 14px; border-radius: 2px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 720px) { .grid2 { grid-template-columns: 1fr; } }
.card {
border: 1px solid var(--line); border-radius: var(--radius-md);
background: var(--panel); padding: 16px 18px;
}
.card.src-a { border-color: rgba(34,211,238,.4); background: linear-gradient(135deg, var(--panel), rgba(34,211,238,.04)); }
.card.src-b { border-color: rgba(74,222,128,.4); background: linear-gradient(135deg, var(--panel), rgba(74,222,128,.04)); }
.card h3 { font-size: 12px; font-weight: 700; letter-spacing: .1em; margin-bottom: 4px; }
.card.src-a h3 { color: var(--cyan); }
.card.src-b h3 { color: var(--green); }
.card .tag { font-size: 10px; color: var(--faint); letter-spacing: .12em; display: block; margin-bottom: 10px; }
.card ul { list-style: none; display: flex; flex-direction: column; gap: 7px; }
.card li { font-size: 12px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; line-height: 1.6; }
.card li::before { content: '→'; color: var(--faint); flex-shrink: 0; }
.card li b { color: var(--ink); font-weight: 600; }
code { background: var(--bg-deep); border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; font-size: 12px; color: var(--amber); }
.callout {
border-left: 3px solid var(--amber); background: var(--amber-dim);
border-radius: var(--radius-sm); padding: 12px 16px; margin: 16px 0;
font-size: 12px; color: var(--ink); line-height: 1.8;
}
.callout.violet { border-left-color: var(--violet); background: var(--violet-dim); }
.callout b { color: var(--ink); }
.formula {
background: var(--bg-deep); border: 1px solid var(--line); border-radius: var(--radius-md);
padding: 16px 18px; font-size: 13px; color: var(--ink); margin: 12px 0; line-height: 1.9;
}
.formula .op { color: var(--cyan); }
.formula small { display: block; color: var(--faint); font-size: 11px; margin-top: 8px; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; }
th, td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; font-size: 10px; letter-spacing: .12em; text-transform: uppercase; }
td { color: var(--ink); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: var(--panel-2); }
.model-name { color: var(--cyan); font-weight: 600; }
ul.plain { list-style: none; display: flex; flex-direction: column; gap: 8px; margin: 8px 0; }
ul.plain li { font-size: 12px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; line-height: 1.7; }
ul.plain li::before { content: '▍'; color: var(--green); flex-shrink: 0; font-size: 10px; }
ul.plain li b { color: var(--ink); }
.foot { margin-top: 44px; font-size: 11px; color: var(--faint); border-top: 1px solid var(--line); padding-top: 14px; line-height: 1.8; }
.src-ref { color: var(--faint); font-size: 11px; }
a { color: var(--cyan); }
</style>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="page">
<div class="header">
<div class="logo">MAESTRO<span></span></div>
<div class="subtitle">用量与成本计算说明 · USAGE &amp; COST EXPLAINED</div>
<button class="theme-btn" onclick="document.documentElement.dataset.theme=document.documentElement.dataset.theme==='dark'?'light':'dark'">⊙ 切换主题</button>
</div>
<p class="lead">控制台里的「用量」其实来自<strong>两个互不相交的数据源</strong>,口径不同、覆盖范围不同,所以经常看着「对不上」。本文把两者来源、为什么对不上、成本怎么算、按天/周/月怎么分桶讲清楚。</p>
<!-- ── 两个数据源 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--cyan)"></div>两个数据源 · TWO SOURCES</div>
<div class="grid2">
<div class="card src-a">
<h3>A · 账号级订阅额度</h3>
<span class="tag">额度条 / QuotaMeter(顶栏)</span>
<ul>
<li>daemon 直连 <code>GET api.anthropic.com/api/oauth/usage</code>,取 <b>five_hour / seven_day</b> 的利用率百分比。</li>
<li>覆盖范围 = <b>整个 Claude Code 账号</b>:你手敲的交互会话、别的项目、别的工具,全部计入。</li>
<li>maestro 只能拿到这个「总量百分比」,<b>拿不到明细</b>,也无法拆出「哪部分是 maestro 烧的」。</li>
<li>来源:<span class="src-ref">src/daemon/usage.ts</span></li>
</ul>
</div>
<div class="card src-b">
<h3>B · maestro 自有 run 的 cost / token</h3>
<span class="tag">AGENT 卡 2×2 + 用量详情弹框</span>
<ul>
<li>每次 maestro worker 跑 CC 调用,从 result 读 <b>四类 token</b> 写进 <code>outbox.ndjson</code>daemon 折算累加进 <code>runs.cost_usd / runs.usage</code></li>
<li>覆盖范围 = <b>仅 maestro 的任务执行</b>executor + 双复审 + 解冲突等),你自己的会话<b>不在内</b></li>
<li>天然比额度条少 —— 它只统计自己发起的 run。</li>
<li>来源:<span class="src-ref">cc.ts → ingest.ts → store.setRunUsage() / costSummary() / usageDetail()</span></li>
</ul>
</div>
</div>
<div class="callout violet">
<b>「最近只有 maestro 在跑,却还有其他 token 消费」的真相:</b><br>
那是你<b>自己用 Claude Code 烧的额度</b>(交互会话 / 其他工具)。它只反映在<b>数据源 A(账号级额度条)</b>里,maestro 采集不到、也算不进数据源 B 的明细,但额度条照样会动。<b>这不是 bug,是两个数据源口径不同。</b> 控制台已分别标注:额度条标「账号级,含交互会话」,AGENT 卡 / 详情标「仅统计 maestro 任务执行」。
</div>
<!-- ── 成本如何计算 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--green)"></div>成本如何计算 · COST FORMULA</div>
<p class="lead">数据源 B 的成本是<strong>逐次 CC 调用精确折算后累加</strong>的。公式:</p>
<div class="formula">
成本(USD) = <span class="op">Σ</span>( input·P<sub>in</sub> <span class="op">+</span> output·P<sub>out</sub> <span class="op">+</span> cacheRead·P<sub>cr</sub> <span class="op">+</span> cacheWrite·P<sub>cw</sub> ) <span class="op">/</span> 1,000,000
<small>四类 token 各按其模型单价折算;单价单位 = USD / 每 1M token。一个 run 可能含多次 CC 调用(executor + 双复审 / resume / 回退),每次按各自模型精确折算后求和 → <code>runs.cost_usd</code> 准确。来源:src/model/pricing.ts · store.setRunUsage()</small>
</div>
<table>
<thead>
<tr>
<th>模型</th>
<th class="num">input</th>
<th class="num">output</th>
<th class="num">cacheRead</th>
<th class="num">cacheWrite</th>
</tr>
</thead>
<tbody>
<tr><td class="model-name">claude-opus-4-8</td><td class="num">15</td><td class="num">75</td><td class="num">1.5</td><td class="num">18.75</td></tr>
<tr><td class="model-name">claude-sonnet-4-6</td><td class="num">3</td><td class="num">15</td><td class="num">0.3</td><td class="num">3.75</td></tr>
<tr><td class="model-name">claude-haiku-4-5</td><td class="num">1</td><td class="num">5</td><td class="num">0.1</td><td class="num">1.25</td></tr>
</tbody>
</table>
<p class="lead" style="font-size:11px">单价单位:USD / 1M token。模型名归一:精确命中优先,否则按已知前缀匹配(容忍日期 / 区域后缀,如 <code>claude-opus-4-8-20260101</code>)。源:<span class="src-ref">src/model/pricing.ts · MODEL_PRICES</span></p>
<div class="callout">
<b>$ 是按 API 列表价的名义估算,不是订阅实际扣费。</b> 它衡量「这些 run 若按 API 计价值多少钱」,便于横向比较项目/模型开销;你的实际账单取决于订阅套餐,二者不应直接相等。控制台 <code>$</code> 处标注「按 API 列表价估算,非订阅实际扣费」,并以 <code></code> 前缀提示。
</div>
<!-- ── 分桶与时区 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--amber)"></div>按天 / 周 / 月分桶与时区 · BUCKETING</div>
<ul class="plain">
<li><b>按天</b>:近 30 天,桶 = 本地 <code>YYYY-MM-DD</code></li>
<li><b>按周</b>:近 12 周,桶 = 本地<b>周一</b> <code>YYYY-MM-DD</code>(滚动 7 天窗)。</li>
<li><b>按月</b>:近 12 个月,桶 = 本地 <code>YYYY-MM</code></li>
<li>「今天 / 本月」窗口起点(<code>periodStartISO</code>)、预算护栏窗口同样以<b>本地零点</b>为基准。</li>
</ul>
<div class="callout">
<b>本次修复(时区分桶):</b> 此前分桶与窗口起点全用 <b>UTC</b>UTC+8 用户在本地 00:0008:00 跑的 run 会被算进<b>前一天</b>、当天窗口晚 8 小时开始 → 柱状图整体偏移一天、当天偏小。现已统一改为 <b>daemon 本地时区</b>local-first 单机,daemon 时区即用户时区,零配置)。SQL 比较的是绝对时刻,本地零点转 ISO 后比较仍正确。源:<span class="src-ref">store.ts · localDay / localMonth / periodStartISO / weekStartLabel / usageDetail</span>
</div>
<!-- ── 已知近似 ── -->
<div class="sec-head"><div class="sec-mark" style="background:var(--red)"></div>已知近似 · KNOWN APPROXIMATIONS</div>
<ul class="plain">
<li><b>分模型 token 为近似。</b> <code>setRunUsage</code> 把累计 token 连同<b>最后一次</b> CC 调用的 model 一起存(<code>{...usage, model}</code>);总 <code>cost_usd</code> 逐次精确折算 → <b>总花费准</b>,但 byModel 的 token / cost 拆分按「run 末次模型」归集,<b>不精确</b>。控制台模型列以 <code></code> 角标提示。</li>
<li><b>未知模型记 0。</b> 若配置了非 <code>opus-4-8 / sonnet-4-6 / haiku-4-5</code> 前缀的模型,<code>priceOf</code> 命中失败 → <code>computeCost</code> 返回 0(不阻断执行,仅成本记 0)。</li>
<li><b>$ 为名义估算</b>,非订阅实账单(见上)。</li>
</ul>
<div class="foot">
单一事实源:成本计价集中于 <code>src/model/pricing.ts</code>,分桶 / 窗口集中于 <code>src/store/store.ts</code><br>
数据源 A(额度):<code>src/daemon/usage.ts</code>;数据源 B(明细):<code>cc.ts → ingest.ts → store.ts</code><br>
本文随实现变化更新;新增 / 迁移文档请同步登记 <code>docs/index.html</code>
</div>
</div>
</body>
</html>