6c9b05e575
精确到项目级的成本/预算护栏,成本按「模型 × 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>
65 lines
3.1 KiB
TypeScript
65 lines
3.1 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: 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();
|
||
});
|