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>
This commit is contained in:
wangjia
2026-06-24 15:29:44 +08:00
parent 0a1f500c12
commit 6c9b05e575
17 changed files with 366 additions and 32 deletions
+12 -8
View File
@@ -1,4 +1,4 @@
import type { Project, Task } from '../model/types.js';
import type { Project, Task, UsageTokens } from '../model/types.js';
import { git, type WorktreeInfo } from './worktree.js';
import { runClaude } from './cc.js';
import { pickModel } from './models.js';
@@ -13,6 +13,8 @@ export interface RunnerResult {
finalText?: string | null;
/** 实际使用的模型(发生回退时为回退模型) */
modelUsed?: string;
/** 本次 CC 的 token 用量(供预算成本折算) */
usage?: UsageTokens;
error?: string;
}
@@ -83,16 +85,16 @@ export async function runTask(task: Task, project: Project, worktree: WorktreeIn
});
if (!cc.ok) {
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, error: cc.error ?? '未知错误' };
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error ?? '未知错误' };
}
// 兜底:CC 没 commit 时由 runner 代为提交
try {
await ensureCommitted(worktree.dir, task);
} catch (e) {
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, error: `兜底提交失败:${(e as Error).message}` };
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: `兜底提交失败:${(e as Error).message}` };
}
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed };
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage };
}
// ───────────────────────── planner(拆解 Hard / 写方案 Medium)─────────────────────────
@@ -104,6 +106,8 @@ export interface PlannerResult {
transcriptRef: string | null;
sessionId: string | null;
finalText: string | null; // CC 的最终文本:spec=方案正文;decompose=分析 + 末尾 fenced JSON
modelUsed?: string;
usage?: UsageTokens;
error?: string;
}
@@ -170,7 +174,7 @@ export async function runPlanner(task: Task, project: Project, kind: PlanKind, r
permissionMode: 'acceptEdits', // planner 只读,不会触发编辑;保持工具流不被 prompt 卡住
allowedTools: ['Read', 'Glob', 'Grep', 'Bash(git log:*)', 'Bash(git diff:*)', 'Bash(git show:*)'], // 纯只读:无 Edit/Write,零副作用
});
return { ok: cc.ok, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, error: cc.error };
return { ok: cc.ok, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error };
}
/** 解冲突任务的提示词:worker 已在 worktree 内触发 git merge 制造冲突态,CC 负责逐个解决。 */
@@ -230,13 +234,13 @@ export async function runConflict(
});
if (!cc.ok) {
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, error: cc.error ?? '解冲突失败' };
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: null, modelUsed: cc.modelUsed, usage: cc.usage, error: cc.error ?? '解冲突失败' };
}
// 兜底提交(CC 应已 commit,但防万一)
try {
await ensureCommitted(worktree.dir, task);
} catch (e) {
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, error: `兜底提交失败:${(e as Error).message}` };
return { ok: false, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage, error: `兜底提交失败:${(e as Error).message}` };
}
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed };
return { ok: true, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, finalText: cc.finalText || null, modelUsed: cc.modelUsed, usage: cc.usage };
}