Files
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

105 lines
5.3 KiB
TypeScript
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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store } from '../src/store/index.js';
import { computeCost, addUsage } from '../src/model/pricing.js';
test('pricing: computeCost 按模型×token 折算(USD/1M', () => {
// opus: in15 / out75 → 各 1M token = 15 + 75 = 90
assert.equal(computeCost('claude-opus-4-8', { input: 1_000_000, output: 1_000_000, cacheRead: 0, cacheWrite: 0 }), 90);
// sonnet: in3 → 2M input = 6
assert.equal(computeCost('claude-sonnet-4-6', { input: 2_000_000, output: 0, cacheRead: 0, cacheWrite: 0 }), 6);
// haiku: cacheRead 0.1 → 10M = 1
assert.equal(computeCost('claude-haiku-4-5', { input: 0, output: 0, cacheRead: 10_000_000, cacheWrite: 0 }), 1);
// 未知模型 → 0(不阻断)
assert.equal(computeCost('mystery-model', { input: 9_999_999, output: 0, cacheRead: 0, cacheWrite: 0 }), 0);
// 前缀匹配(容忍日期后缀)
assert.equal(computeCost('claude-opus-4-8-20260101', { input: 1_000_000, output: 0, cacheRead: 0, cacheWrite: 0 }), 15);
});
test('pricing: addUsage 逐项累加', () => {
const a = { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 };
assert.deepEqual(addUsage(a, a), { input: 2, output: 4, cacheRead: 6, cacheWrite: 8 });
});
test('store: setRunUsage 累加成本 + costSummary 聚合', () => {
const s = new Store(':memory:');
const p = s.createProject({ name: 'b', repoPath: '/tmp/b-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
const r = s.startRun(t.id, 'executor');
// 一个 run 内多次 CC 调用(executor + 复审)→ 累加
const c1 = s.setRunUsage(r.id, { input: 1_000_000, output: 0, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8');
assert.equal(c1, 15);
const c2 = s.setRunUsage(r.id, { input: 0, output: 1_000_000, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8');
assert.equal(c2, 90);
const sum = s.costSummary('month');
assert.equal(sum.total, 90);
assert.equal(sum.byProject.length, 1);
assert.equal(sum.byProject[0].projectId, p.id);
assert.equal(sum.byProject[0].costUsd, 90);
assert.equal(sum.byProject[0].tokens, 2_000_000);
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:0008: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() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
const r = s.startRun(t.id, 'executor');
s.setRunUsage(r.id, { input: 0, output: 1_000_000, cacheRead: 0, cacheWrite: 0 }, 'claude-opus-4-8'); // $75
// 无预算 → 不干预
assert.equal(s.enforceBudget(p.id), false);
assert.equal(s.getProject(p.id)!.status, 'active');
// 设 $50 上限 → 超额、暂停、发事件
s.patchProject(p.id, { budgetUsd: 50, budgetPeriod: 'month' });
assert.equal(s.enforceBudget(p.id), true);
assert.equal(s.getProject(p.id)!.status, 'paused');
const ev = s.listEvents(p.id).find((e) => e.type === 'budget.exceeded');
assert.ok(ev, '应有 budget.exceeded 事件');
assert.equal((ev!.payload as { budget: number }).budget, 50);
s.close();
});