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:
+14
-7
@@ -28,6 +28,7 @@ export function adaptProject(p) {
|
||||
state, pending: s.pending || 0, agents: s.executing || 0,
|
||||
// 透传给 ConfigPanel 用的原始字段
|
||||
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
|
||||
logo: p.logo, budgetUsd: p.budgetUsd ?? null, budgetPeriod: p.budgetPeriod ?? 'month',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,14 +136,20 @@ export function deriveGlobal(projects, agentsResp) {
|
||||
};
|
||||
}
|
||||
|
||||
// 跨项目 agent 概览。token/成本属 ⑧ 标 ★ 的新 /api/usage(预算特性),暂留 0。
|
||||
export function deriveAgentSummary(projects, agentsResp) {
|
||||
// 跨项目 agent 概览。token/成本来自 /api/usage 的 cost 明细(按当期窗口聚合)。
|
||||
export function deriveAgentSummary(projects, agentsResp, cost) {
|
||||
const byPid = new Map((cost?.byProject || []).map((c) => [c.projectId, c]));
|
||||
const tokensTotal = (cost?.byProject || []).reduce((sum, c) => sum + (c.tokens || 0), 0);
|
||||
return {
|
||||
tokensWeek: 0, runsWeek: 0, costWeek: 0, activeNow: agentsResp?.totalActive ?? 0,
|
||||
byProject: projects.map((p) => ({
|
||||
id: p.id, name: p.name, hue: p.hue,
|
||||
active: p.agents || 0, runs: 0, tokens: 0,
|
||||
})),
|
||||
tokensWeek: tokensTotal, runsWeek: 0, costWeek: cost?.total || 0, activeNow: agentsResp?.totalActive ?? 0,
|
||||
byProject: projects.map((p) => {
|
||||
const c = byPid.get(p.id);
|
||||
return {
|
||||
id: p.id, name: p.name, hue: p.hue,
|
||||
active: p.agents || 0, runs: 0,
|
||||
tokens: c?.tokens || 0, cost: c?.costUsd || 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -154,6 +154,10 @@ export function App() {
|
||||
if (!evt.projectId || evt.projectId === currentId) {
|
||||
setEventsRaw((list) => [evt, ...list].slice(0, 60));
|
||||
}
|
||||
if (evt.type === 'budget.exceeded') {
|
||||
const pay = evt.payload || {};
|
||||
toast('warn', `⚠ 项目超预算已暂停 · 已用 $${(pay.spend ?? 0).toFixed?.(2) ?? pay.spend} / $${pay.budget}`);
|
||||
}
|
||||
clearTimeout(refreshTimer.current);
|
||||
refreshTimer.current = setTimeout(() => {
|
||||
loadProject(currentId);
|
||||
@@ -178,7 +182,7 @@ export function App() {
|
||||
const events = eventsRaw.map(adaptEvent);
|
||||
const quota = adaptQuota(usage);
|
||||
const global = deriveGlobal(projects, agentsResp);
|
||||
const agentSummary = deriveAgentSummary(projects, agentsResp);
|
||||
const agentSummary = deriveAgentSummary(projects, agentsResp, usage?.cost);
|
||||
const NONTERMINAL = (s) => !['done', 'cancelled', 'decomposed'].includes(s);
|
||||
const counts = {
|
||||
gate: gates.length,
|
||||
@@ -240,7 +244,7 @@ export function App() {
|
||||
counts={counts}
|
||||
onSync={onSync}
|
||||
onToggleConfig={() => setShowConfig(!showConfig)} /> : null}
|
||||
{showConfig && project ? <window.MaestroKitConfigPanel project={project} t={t}
|
||||
{showConfig && project ? <window.MaestroKitConfigPanel project={project} t={t} lang={lang}
|
||||
onSave={saveConfig}
|
||||
onSync={onSync}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
|
||||
Reference in New Issue
Block a user