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:
@@ -32,6 +32,10 @@ export function openDb(file: string): Database.Database {
|
||||
ensureColumn(db, 'tasks', 'next_eligible_at', 'next_eligible_at TEXT'); // 持久化退避(多进程执行)
|
||||
ensureColumn(db, 'runs', 'worker_pid', 'worker_pid INTEGER'); // worker 进程 pid
|
||||
ensureColumn(db, 'runs', 'last_seq', 'last_seq INTEGER NOT NULL DEFAULT 0'); // outbox ingest 游标
|
||||
ensureColumn(db, 'runs', 'usage', 'usage TEXT'); // token 用量 JSON {input,output,cacheRead,cacheWrite}
|
||||
ensureColumn(db, 'runs', 'cost_usd', 'cost_usd REAL'); // 该 run 折算成本 USD(按模型×token)
|
||||
ensureColumn(db, 'projects', 'budget_usd', 'budget_usd REAL'); // 项目当期预算上限 USD(null=不限)
|
||||
ensureColumn(db, 'projects', 'budget_period', "budget_period TEXT NOT NULL DEFAULT 'month'"); // 预算周期 day|month
|
||||
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
return db;
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ProjectRow {
|
||||
status: string; created_at: string;
|
||||
last_sync_at: string | null; logo: string | null; sort_order: number;
|
||||
checks: string | null; auto_approve_plan: number; auto_approve_exec: number;
|
||||
budget_usd: number | null; budget_period: string | null;
|
||||
}
|
||||
export interface TaskRow {
|
||||
id: string; project_id: string; parent_id: string | null; depth: number;
|
||||
@@ -31,6 +32,7 @@ export interface RunRow {
|
||||
status: string; started_at: string; ended_at: string | null;
|
||||
transcript_ref: string | null; claude_session_id: string | null; error: string | null;
|
||||
worker_pid: number | null; last_seq: number;
|
||||
usage: string | null; cost_usd: number | null;
|
||||
}
|
||||
export interface EventRow {
|
||||
id: string; project_id: string; task_id: string | null; type: string; payload: string; at: string;
|
||||
@@ -46,6 +48,8 @@ export function rowToProject(r: ProjectRow): Project {
|
||||
checks: r.checks ?? null,
|
||||
autoApprovePlan: Boolean(r.auto_approve_plan),
|
||||
autoApproveExec: Boolean(r.auto_approve_exec),
|
||||
budgetUsd: r.budget_usd ?? null,
|
||||
budgetPeriod: (r.budget_period === 'day' ? 'day' : 'month'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +99,8 @@ export function rowToRun(r: RunRow): Run {
|
||||
startedAt: r.started_at, endedAt: r.ended_at,
|
||||
transcriptRef: r.transcript_ref, claudeSessionId: r.claude_session_id, error: r.error,
|
||||
workerPid: r.worker_pid ?? null, lastSeq: r.last_seq ?? 0,
|
||||
usage: r.usage ? (JSON.parse(r.usage) as Run['usage']) : null,
|
||||
costUsd: r.cost_usd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
sort_order INTEGER NOT NULL DEFAULT 0, -- 侧栏排序(小在前)
|
||||
checks TEXT, -- 分项检查命令 JSON(如 {"lint":"npm run lint"})
|
||||
auto_approve_plan INTEGER NOT NULL DEFAULT 0, -- 全 easy 子任务时跳过 plan_review(0=关)
|
||||
auto_approve_exec INTEGER NOT NULL DEFAULT 0 -- 双复审 approve 后跳过 exec_review(0=关)
|
||||
auto_approve_exec INTEGER NOT NULL DEFAULT 0, -- 双复审 approve 后跳过 exec_review(0=关)
|
||||
budget_usd REAL, -- 项目当期预算上限 USD(null=不限)
|
||||
budget_period TEXT NOT NULL DEFAULT 'month' -- 预算周期 day | month
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
@@ -72,7 +74,9 @@ CREATE TABLE IF NOT EXISTS runs (
|
||||
claude_session_id TEXT,
|
||||
error TEXT,
|
||||
worker_pid INTEGER, -- 多进程执行:worker 进程 pid(daemon 写,判活用)
|
||||
last_seq INTEGER NOT NULL DEFAULT 0 -- 已 ingest 的 outbox 最大 seq(幂等游标)
|
||||
last_seq INTEGER NOT NULL DEFAULT 0, -- 已 ingest 的 outbox 最大 seq(幂等游标)
|
||||
usage TEXT, -- token 用量 JSON {input,output,cacheRead,cacheWrite}
|
||||
cost_usd REAL -- 该 run 折算成本 USD(按模型×token,见 model/pricing.ts)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id, started_at);
|
||||
|
||||
|
||||
+103
-1
@@ -12,6 +12,7 @@ import {
|
||||
type Metrics, type ModelUsage, percentile, ratio, estimateCostUnits, emptyMetrics,
|
||||
} from '../model/metrics.js';
|
||||
import { resolvedExecutorModels } from '../executor/models.js';
|
||||
import { computeCost, addUsage, EMPTY_USAGE, type UsageTokens } from '../model/pricing.js';
|
||||
import {
|
||||
type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL,
|
||||
} from '../model/status.js';
|
||||
@@ -19,6 +20,15 @@ import {
|
||||
const now = (): string => new Date().toISOString();
|
||||
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
|
||||
|
||||
/** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */
|
||||
function periodStartISO(period: 'day' | 'month'): string {
|
||||
const d = new Date();
|
||||
const start = period === 'day'
|
||||
? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
|
||||
: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
|
||||
return new Date(start).toISOString();
|
||||
}
|
||||
|
||||
export class StoreError extends Error {}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
@@ -34,10 +44,19 @@ export interface PatchProjectInput {
|
||||
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
|
||||
model?: string | null; status?: 'active' | 'paused'; logo?: string | null;
|
||||
maxRetries?: number; timeoutMs?: number;
|
||||
budgetUsd?: number | null; budgetPeriod?: 'day' | 'month';
|
||||
}
|
||||
export interface PatchTaskInput {
|
||||
title?: string; priority?: number; complexity?: Complexity; deps?: string[];
|
||||
}
|
||||
/** 成本明细(按项目 / 按模型 / 总计),供 GET /api/usage 与预算判定 */
|
||||
export interface CostSummary {
|
||||
period: 'day' | 'month';
|
||||
total: number;
|
||||
byProject: Array<{ projectId: string; costUsd: number; tokens: number }>;
|
||||
byModel: Array<{ model: string; costUsd: number; tokens: number }>;
|
||||
}
|
||||
|
||||
/** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */
|
||||
export interface ActiveRun {
|
||||
runId: string; taskId: string; taskTitle: string; kind: string;
|
||||
@@ -111,6 +130,7 @@ export class Store {
|
||||
status: 'active', created_at: now(),
|
||||
last_sync_at: null, logo: null, sort_order: maxOrder + 1,
|
||||
checks: null, auto_approve_plan: 0, auto_approve_exec: 0,
|
||||
budget_usd: null, budget_period: 'month',
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,max_retries,timeout_ms,status,created_at,last_sync_at,logo,sort_order,checks,auto_approve_plan,auto_approve_exec)
|
||||
@@ -198,6 +218,18 @@ export class Store {
|
||||
}
|
||||
sets.push('timeout_ms = @timeout_ms'); args.timeout_ms = patch.timeoutMs;
|
||||
}
|
||||
if (patch.budgetUsd !== undefined) {
|
||||
if (patch.budgetUsd !== null && (!Number.isFinite(patch.budgetUsd) || patch.budgetUsd < 0)) {
|
||||
throw new StoreError('budgetUsd 必须是 >=0 的数字或 null');
|
||||
}
|
||||
sets.push('budget_usd = @budget_usd'); args.budget_usd = patch.budgetUsd;
|
||||
}
|
||||
if (patch.budgetPeriod !== undefined) {
|
||||
if (patch.budgetPeriod !== 'day' && patch.budgetPeriod !== 'month') {
|
||||
throw new StoreError('budgetPeriod 必须是 day | month');
|
||||
}
|
||||
sets.push('budget_period = @budget_period'); args.budget_period = patch.budgetPeriod;
|
||||
}
|
||||
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
|
||||
@@ -899,7 +931,7 @@ export class Store {
|
||||
const rr: RunRow = {
|
||||
id: id('run'), task_id: taskId, kind, worktree: fields.worktree ?? null, branch: fields.branch ?? null,
|
||||
status: 'started', started_at: now(), ended_at: null, transcript_ref: null, claude_session_id: null, error: null,
|
||||
worker_pid: null, last_seq: 0,
|
||||
worker_pid: null, last_seq: 0, usage: null, cost_usd: null,
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO runs (id,task_id,kind,worktree,branch,status,started_at,ended_at,transcript_ref,claude_session_id,error)
|
||||
@@ -930,6 +962,76 @@ export class Store {
|
||||
this.db.prepare(`UPDATE runs SET last_seq = ? WHERE id = ?`).run(seq, runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 累加 run 的 token 用量并按模型折算成本(USD)。一个 run 可能有多次 CC 调用
|
||||
* (executor + 双复审 / resume / 回退),每次 emit 一条 usage 记录 → 累加。
|
||||
* cost 逐次按各自模型精确折算后求和;usage JSON 存累计 token + 最近模型(byModel 近似)。返回累计成本。
|
||||
*/
|
||||
setRunUsage(runId: string, usage: UsageTokens, model: string): number {
|
||||
const cur = this.db.prepare(`SELECT usage, cost_usd FROM runs WHERE id = ?`).get(runId) as
|
||||
{ usage: string | null; cost_usd: number | null } | undefined;
|
||||
if (!cur) throw new StoreError(`run 不存在: ${runId}`);
|
||||
const prevUsage: UsageTokens = cur.usage ? JSON.parse(cur.usage) as UsageTokens : EMPTY_USAGE;
|
||||
const nextUsage = addUsage(prevUsage, usage);
|
||||
const nextCost = (cur.cost_usd ?? 0) + computeCost(model, usage);
|
||||
this.db.prepare(`UPDATE runs SET usage = ?, cost_usd = ? WHERE id = ?`)
|
||||
.run(JSON.stringify({ ...nextUsage, model }), nextCost, runId);
|
||||
return nextCost;
|
||||
}
|
||||
|
||||
/** 项目当期累计成本 USD(run.started_at 落在 day|month 窗口内)。 */
|
||||
projectSpend(projectId: string, period: 'day' | 'month'): number {
|
||||
const since = periodStartISO(period);
|
||||
const r = this.db.prepare(
|
||||
`SELECT COALESCE(SUM(r.cost_usd), 0) AS c FROM runs r JOIN tasks t ON t.id = r.task_id
|
||||
WHERE t.project_id = ? AND r.started_at >= ?`,
|
||||
).get(projectId, since) as { c: number };
|
||||
return r.c;
|
||||
}
|
||||
|
||||
/** 成本明细:按项目 / 按模型 / 总计(period 窗口内,可选限定项目)。 */
|
||||
costSummary(period: 'day' | 'month', projectId?: string): CostSummary {
|
||||
const since = periodStartISO(period);
|
||||
const rows = this.db.prepare(
|
||||
`SELECT t.project_id AS pid, r.usage AS usage, r.cost_usd AS cost
|
||||
FROM runs r JOIN tasks t ON t.id = r.task_id
|
||||
WHERE r.started_at >= ? AND r.cost_usd IS NOT NULL${projectId ? ' AND t.project_id = ?' : ''}`,
|
||||
).all(...(projectId ? [since, projectId] : [since])) as Array<{ pid: string; usage: string | null; cost: number }>;
|
||||
const byProject = new Map<string, { cost: number; tokens: number }>();
|
||||
const byModel = new Map<string, { cost: number; tokens: number }>();
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
total += row.cost;
|
||||
const u = row.usage ? JSON.parse(row.usage) as UsageTokens & { model?: string } : null;
|
||||
const tokens = u ? u.input + u.output + u.cacheRead + u.cacheWrite : 0;
|
||||
const model = u?.model ?? 'unknown';
|
||||
const bp = byProject.get(row.pid) ?? { cost: 0, tokens: 0 }; bp.cost += row.cost; bp.tokens += tokens; byProject.set(row.pid, bp);
|
||||
const bm = byModel.get(model) ?? { cost: 0, tokens: 0 }; bm.cost += row.cost; bm.tokens += tokens; byModel.set(model, bm);
|
||||
}
|
||||
return {
|
||||
period, total,
|
||||
byProject: [...byProject].map(([pid, v]) => ({ projectId: pid, costUsd: v.cost, tokens: v.tokens })),
|
||||
byModel: [...byModel].map(([model, v]) => ({ model, costUsd: v.cost, tokens: v.tokens })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算护栏:项目当期成本达到预算上限时,置 paused 并广播 budget.exceeded(仅在 active→paused 时广播一次)。
|
||||
* 无预算(budgetUsd 为 null/≤0)则不干预。返回是否已超额(供编排器跳过领取)。
|
||||
*/
|
||||
enforceBudget(projectId: string): boolean {
|
||||
const p = this.getProject(projectId);
|
||||
if (!p || p.budgetUsd == null || p.budgetUsd <= 0) return false;
|
||||
const period = p.budgetPeriod ?? 'month';
|
||||
const spend = this.projectSpend(projectId, period);
|
||||
if (spend < p.budgetUsd) return false;
|
||||
if (p.status === 'active') {
|
||||
this.db.prepare(`UPDATE projects SET status = 'paused' WHERE id = ?`).run(projectId);
|
||||
this.emit(projectId, null, 'budget.exceeded', { spend, budget: p.budgetUsd, period });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 所有进行中的 run(status='started'),联 tasks 取任务标题与项目。 */
|
||||
activeRuns(): ActiveRun[] {
|
||||
const rows = this.db.prepare(
|
||||
|
||||
Reference in New Issue
Block a user