Files
maestro/test/budget.test.ts
T
wangjia 6c9b05e575 feat: 项目级成本预算系统(按模型×token 精确计费 + 超额自动暂停)
精确到项目级的成本/预算护栏,成本按「模型 × token 用量」折算(⑧ ★ 新特性)。

定价(单源):
- src/model/pricing.ts:MODEL_PRICES(opus/sonnet/haiku,USD/1M token,分
  input/output/cacheRead/cacheWrite)+ computeCost/addUsage,前缀容错、未知模型记 0

token 捕获链(worker → daemon):
- cc.ts:从 SDK result 消息抓 usage,跨 resume/回退累计;CCResult.usage
- runner/reviewer:结果对象带 usage + modelUsed
- protocol.ts:新增 'usage' outbox 记录;pipeline 每次 CC(executor/复审/planner/
  conflict)后 emit usage(worker 侧写文件,不碰 DB)
- ingest.ts:'usage' → store.setRunUsage(累加、按模型折算)→ enforceBudget

存储 + 护栏:
- schema/db.ts:runs.usage/cost_usd、projects.budget_usd/budget_period(幂等迁移)
- store.ts:setRunUsage(累加)、projectSpend、costSummary(按项目/模型/总计)、
  enforceBudget(超额 → 置 paused + 广播 budget.exceeded)
- 编排器天然停领:orchestrator 既有「跳过 paused 项目」即生效,无需改

API:
- GET /api/usage:并入成本明细 {session,weekly,cost:{period,total,byProject,byModel}},?period/?project
- GET /api/health:{ok,db,inflight,at}
- PATCH /api/projects:支持 budgetUsd/budgetPeriod

前端:
- adapt.js:agentSummary 用真实成本/token;adaptProject 透传预算字段
- app.jsx:budget.exceeded → 告警 toast
- ConfigPanel:预算 $ + 周期(day|month)受控输入

验证:typecheck 干净;206 测试通过(含新增 budget.test.ts 4 例:定价折算/累加/
costSummary/enforceBudget);前端 build + 截图确认预算输入渲染、0 错误。
注:生效需合并后重启 daemon(迁移幂等加列、向后兼容旧库)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:29:44 +08:00

65 lines
3.1 KiB
TypeScript
Raw 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: 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();
});