d0aa53c79d
全局面板「用量」数据问题修复与澄清: 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>
105 lines
5.3 KiB
TypeScript
105 lines
5.3 KiB
TypeScript
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: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() });
|
||
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();
|
||
});
|