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}
|
||||
|
||||
@@ -107,16 +107,23 @@ function Topbar({ project, counts, onSync, onToggleConfig, t, theme, onToggleThe
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigPanel({ project, onSave, onClose, onSync, t }) {
|
||||
function ConfigPanel({ project, onSave, onClose, onSync, t, lang }) {
|
||||
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
|
||||
const [concurrency, setConcurrency] = React.useState(String(project.concurrency));
|
||||
const [autonomy, setAutonomy] = React.useState(project.autonomy);
|
||||
const [logo, setLogo] = React.useState(project.logo || '');
|
||||
const [verifyCmd, setVerifyCmd] = React.useState(project.verifyCmd || '');
|
||||
const [model, setModel] = React.useState(project.model || '');
|
||||
const [budgetUsd, setBudgetUsd] = React.useState(project.budgetUsd != null ? String(project.budgetUsd) : '');
|
||||
const [budgetPeriod, setBudgetPeriod] = React.useState(project.budgetPeriod || 'month');
|
||||
const BL = lang === 'en'
|
||||
? { budget: 'Budget $ (empty=∞)', period: 'Period', day: 'Daily', month: 'Monthly', ph: 'e.g. 20' }
|
||||
: { budget: '预算 $(空=不限)', period: '周期', day: '按天', month: '按月', ph: '如 20' };
|
||||
const save = () => onSave({
|
||||
concurrency: Number(concurrency), autonomy,
|
||||
logo: logo || null, verifyCmd: verifyCmd || null, model: model || null,
|
||||
budgetUsd: budgetUsd.trim() === '' ? null : Number(budgetUsd),
|
||||
budgetPeriod,
|
||||
});
|
||||
return (
|
||||
<section style={{ position: 'relative', zIndex: 30, background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: '12px 14px', marginTop: 12, animation: 'maestro-rise .18s ease both' }}>
|
||||
@@ -134,6 +141,11 @@ function ConfigPanel({ project, onSave, onClose, onSync, t }) {
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.verifyCmd} placeholder={t.verifyPh} value={verifyCmd} onChange={setVerifyCmd} style={{ width: '100%' }} /></span>
|
||||
<span style={{ flex: 1, minWidth: 170, display: 'flex' }}><Input label={t.model} placeholder={t.modelPh} value={model} onChange={setModel} style={{ width: '100%' }} /></span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<Input label={BL.budget} type="number" placeholder={BL.ph} value={budgetUsd} onChange={setBudgetUsd} width={150} />
|
||||
<Select label={BL.period} value={budgetPeriod} onChange={setBudgetPeriod}
|
||||
options={[{ value: 'day', label: BL.day }, { value: 'month', label: BL.month }]} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, paddingTop: 10, borderTop: '1px dashed var(--line-soft)' }}>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--faint)', letterSpacing: '.06em' }}>{t.lastSync}</span>
|
||||
<Button size="xs" onClick={onSync}>⟳ {t.sync}</Button>
|
||||
|
||||
+19
-2
@@ -83,6 +83,8 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
if (b.logo !== undefined) patch.logo = b.logo === null || b.logo === '' ? null : String(b.logo);
|
||||
if (b.maxRetries !== undefined) patch.maxRetries = Number(b.maxRetries);
|
||||
if (b.timeoutMs !== undefined) patch.timeoutMs = Number(b.timeoutMs);
|
||||
if (b.budgetUsd !== undefined) patch.budgetUsd = b.budgetUsd === null || b.budgetUsd === '' ? null : Number(b.budgetUsd);
|
||||
if (b.budgetPeriod !== undefined) patch.budgetPeriod = b.budgetPeriod as 'day' | 'month';
|
||||
return projectOut(store.patchProject(id, patch));
|
||||
});
|
||||
|
||||
@@ -145,8 +147,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
return { totalActive: runs.length, agents, usage: await getUsage() };
|
||||
});
|
||||
|
||||
// Claude 订阅额度(5 小时 + 周窗口)单独透传;查询失败 → null(前端/CLI 降级显示)
|
||||
app.get('/api/usage', async () => await getUsage());
|
||||
// 用量:Claude 订阅额度(5h+周配额,查询失败→null)+ 成本明细(按项目/模型/总计,按 period 窗口)。
|
||||
// ?period=day|month(默认 month);?project=<id> 限定单项目成本。
|
||||
app.get('/api/usage', async (req) => {
|
||||
const q = req.query as { period?: string; project?: string };
|
||||
const period = q.period === 'day' ? 'day' : 'month';
|
||||
const quota = await getUsage();
|
||||
const cost = store.costSummary(period, q.project && q.project.trim() ? q.project.trim() : undefined);
|
||||
return { ...(quota ?? {}), cost };
|
||||
});
|
||||
|
||||
// 健康检查:daemon 存活 + DB 可读 + 在途 run 数。
|
||||
app.get('/api/health', () => {
|
||||
let db = true;
|
||||
let inflight = 0;
|
||||
try { inflight = store.activeRuns().length; } catch { db = false; }
|
||||
return { ok: db, db, inflight, at: new Date().toISOString() };
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/tasks', (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -173,6 +173,18 @@ function applyRecord(store: Store, log: IngestLogger, taskId: string, runId: str
|
||||
return;
|
||||
}
|
||||
|
||||
case 'usage': {
|
||||
// token 用量:累加进 run 的 usage/cost(按模型折算),再检当期预算(超额则暂停项目)。
|
||||
const cost = store.setRunUsage(runId, rec.usage, rec.model);
|
||||
const task = store.getTask(taskId);
|
||||
if (task) {
|
||||
const exceeded = store.enforceBudget(task.projectId);
|
||||
if (exceeded) log.info(`项目 ${task.projectId} 当期成本超预算,已自动暂停`);
|
||||
}
|
||||
log.info(`task=${taskId} run=${runId} 用量入账 model=${rec.model} 累计成本=$${cost.toFixed(4)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'done':
|
||||
// worker 即将退出:终态标记。run 已由 result/failed 收尾,这里无须额外 DB 写。
|
||||
log.info(`task=${taskId} run=${runId} worker 退出`);
|
||||
|
||||
+22
-2
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { isModelError, pickFallbackModel } from './models.js';
|
||||
import { type UsageTokens, EMPTY_USAGE, addUsage } from '../model/pricing.js';
|
||||
|
||||
/** 转录目录:<MAESTRO_DATA_DIR 或 ~/.maestro>/transcripts */
|
||||
export function transcriptDir(): string {
|
||||
@@ -27,6 +28,7 @@ export interface CCResult {
|
||||
transcriptRef: string;
|
||||
modelUsed: string; // 实际使用的模型(发生回退时为回退模型)
|
||||
fellBack: boolean;
|
||||
usage: UsageTokens; // 本 run 的 token 用量(含 resume/回退累计),供预算成本折算
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -34,6 +36,7 @@ interface AttemptResult {
|
||||
ok: boolean;
|
||||
finalText: string;
|
||||
sessionId: string | null;
|
||||
usage: UsageTokens; // 本次会话的 token 用量(result 消息提供;失败/无 result 时为空)
|
||||
error?: string;
|
||||
/**
|
||||
* 失败是否属于「会话异常中断」——即流式断了 / 没收到 result 就结束(SDK 偶发抖动)。
|
||||
@@ -65,6 +68,7 @@ async function attempt(
|
||||
let sawResult = false;
|
||||
let resumable = false;
|
||||
let timedOut = false;
|
||||
let usage: UsageTokens = { ...EMPTY_USAGE };
|
||||
|
||||
const abort = new AbortController();
|
||||
const timeoutMs = extra?.timeoutMs ?? opts.timeoutMs;
|
||||
@@ -91,6 +95,18 @@ async function attempt(
|
||||
if (typeof sid === 'string' && sid) sessionId = sid;
|
||||
if (message.type === 'result') {
|
||||
sawResult = true;
|
||||
const u = (message as { usage?: unknown }).usage as {
|
||||
input_tokens?: number; output_tokens?: number;
|
||||
cache_read_input_tokens?: number; cache_creation_input_tokens?: number;
|
||||
} | undefined;
|
||||
if (u) {
|
||||
usage = {
|
||||
input: u.input_tokens ?? 0,
|
||||
output: u.output_tokens ?? 0,
|
||||
cacheRead: u.cache_read_input_tokens ?? 0,
|
||||
cacheWrite: u.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
if (message.subtype === 'success' && !message.is_error) {
|
||||
resultOk = true;
|
||||
const txt = (message as { result?: unknown }).result;
|
||||
@@ -116,8 +132,8 @@ async function attempt(
|
||||
clearTimeout(killer);
|
||||
}
|
||||
|
||||
if (!resultOk) return { ok: false, finalText: '', sessionId, error: resultError ?? '未知错误', resumable };
|
||||
return { ok: true, finalText, sessionId };
|
||||
if (!resultOk) return { ok: false, finalText: '', sessionId, usage, error: resultError ?? '未知错误', resumable };
|
||||
return { ok: true, finalText, sessionId, usage };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,12 +159,14 @@ export async function runClaude(opts: CCOptions, queryImpl: CCQuery = query): Pr
|
||||
let r = await attempt(opts, opts.model, out, queryImpl);
|
||||
let modelUsed = opts.model;
|
||||
let fellBack = false;
|
||||
let usage = r.usage; // 累计各次尝试(resume/回退)的 token 用量
|
||||
|
||||
// 1. SDK 抖动续跑:会话异常中断 + 有 sessionId + 非模型错误(那归回退处理) + 未关闭
|
||||
if (!r.ok && r.resumable && r.sessionId && !isModelError(r.error ?? '') && process.env.MAESTRO_SDK_RESUME !== '0') {
|
||||
out.write(`${JSON.stringify({ type: 'maestro.session_resume', sessionId: r.sessionId, reason: r.error })}\n`);
|
||||
const remaining = Math.max(opts.timeoutMs - (Date.now() - t0), RESUME_MIN_TIMEOUT_MS);
|
||||
r = await attempt(opts, opts.model, out, queryImpl, { resume: r.sessionId, timeoutMs: remaining });
|
||||
usage = addUsage(usage, r.usage);
|
||||
}
|
||||
|
||||
if (!r.ok && r.error && isModelError(r.error)) {
|
||||
@@ -156,6 +174,7 @@ export async function runClaude(opts: CCOptions, queryImpl: CCQuery = query): Pr
|
||||
if (fb) {
|
||||
out.write(`${JSON.stringify({ type: 'maestro.model_fallback', from: opts.model, to: fb, reason: r.error })}\n`);
|
||||
r = await attempt(opts, fb, out, queryImpl);
|
||||
usage = addUsage(usage, r.usage);
|
||||
modelUsed = fb;
|
||||
fellBack = true;
|
||||
}
|
||||
@@ -169,6 +188,7 @@ export async function runClaude(opts: CCOptions, queryImpl: CCQuery = query): Pr
|
||||
transcriptRef,
|
||||
modelUsed,
|
||||
fellBack,
|
||||
usage,
|
||||
...(r.error ? { error: fellBack ? `${r.error}(已回退至 ${modelUsed} 重试)` : r.error } : {}),
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// (worker 退出) → emit done
|
||||
|
||||
import type { JobSpec, OutboxPayload, ReviewReport, DecomposeResult } from './protocol.js';
|
||||
import type { UsageTokens } from '../model/types.js';
|
||||
import { createWorktree, worktreeDiff, git, type WorktreeDiff, type WorktreeInfo } from './worktree.js';
|
||||
import { runTask, runPlanner, runConflict, type RunnerFn, type PlannerFn } from './runner.js';
|
||||
import { runVerify, type VerifyFn } from './verify.js';
|
||||
@@ -78,15 +79,25 @@ async function runOneReview(
|
||||
wt: WorktreeInfo,
|
||||
reviewRunId: string,
|
||||
executorReport: string,
|
||||
emit: Emit,
|
||||
): Promise<ReviewReport> {
|
||||
try {
|
||||
const rv = await fn(job.task, job.project, wt, reviewRunId, executorReport);
|
||||
emitUsage(emit, rv); // 复审也耗 token,计入本 run 成本
|
||||
return { summary: rv.summary, verdict: rv.verdict, transcriptRef: rv.transcriptRef };
|
||||
} catch (e) {
|
||||
return { summary: `自动复审失败:${(e as Error).message}`, verdict: null, transcriptRef: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 把一次 CC 的 token 用量发成 usage outbox 记录(有用量才发;daemon ingest → 累加成本)。 */
|
||||
function emitUsage(emit: Emit, r: { usage?: UsageTokens; modelUsed?: string }): void {
|
||||
const u = r.usage;
|
||||
if (!u || !r.modelUsed) return;
|
||||
if (u.input + u.output + u.cacheRead + u.cacheWrite <= 0) return;
|
||||
emit({ type: 'usage', usage: u, model: r.modelUsed });
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯执行管线。复刻旧 in-process 流程的【执行与产出】,但所有 store.* 改成 emit(outbox):
|
||||
* 1. createWorktree
|
||||
@@ -121,6 +132,7 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
|
||||
// 2. 执行
|
||||
emit({ type: 'phase', phase: 'executing' });
|
||||
const rr = await deps.runTask(job.task, job.project, wt, job.runId);
|
||||
emitUsage(emit, rr);
|
||||
if (!rr.ok) {
|
||||
emit({ type: 'failed', error: rr.error ?? '执行失败', transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||||
emit({ type: 'done' });
|
||||
@@ -144,8 +156,8 @@ export async function runPipeline(job: JobSpec, deps: PipelineDeps = realDeps, e
|
||||
emit({ type: 'phase', phase: 'reviewing' });
|
||||
const report = rr.finalText ?? '';
|
||||
const [code, security] = await Promise.all([
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report),
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report, emit),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report, emit),
|
||||
]);
|
||||
|
||||
// 6. 成功终态
|
||||
@@ -172,6 +184,7 @@ async function runPlannerPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit):
|
||||
const kind = job.runKind === 'planner-spec' ? 'spec' : 'decompose';
|
||||
emit({ type: 'phase', phase: kind === 'spec' ? 'speccing' : 'decomposing' });
|
||||
const r = await deps.runPlanner(job.task, job.project, kind, job.runId);
|
||||
emitUsage(emit, r);
|
||||
if (!r.ok || !r.finalText) {
|
||||
emit({ type: 'failed', error: r.error ?? 'planner 无输出', transcriptRef: r.transcriptRef, sessionId: r.sessionId });
|
||||
emit({ type: 'done' });
|
||||
@@ -234,6 +247,7 @@ async function runConflictPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit)
|
||||
// 4. CC 解冲突
|
||||
emit({ type: 'phase', phase: 'resolving' });
|
||||
const rr = await deps.runConflict(job.task, job.project, wt, job.runId, conflictFiles);
|
||||
emitUsage(emit, rr);
|
||||
if (!rr.ok) {
|
||||
emit({ type: 'failed', error: rr.error ?? '解冲突失败', transcriptRef: rr.transcriptRef, sessionId: rr.sessionId });
|
||||
emit({ type: 'done' });
|
||||
@@ -247,8 +261,8 @@ async function runConflictPipeline(job: JobSpec, deps: PipelineDeps, emit: Emit)
|
||||
emit({ type: 'phase', phase: 'reviewing' });
|
||||
const report = rr.finalText ?? '';
|
||||
const [code, security] = await Promise.all([
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report),
|
||||
runOneReview(deps.reviewCode, job, wt, `${job.runId}.review`, report, emit),
|
||||
runOneReview(deps.reviewSecurity, job, wt, `${job.runId}.security`, report, emit),
|
||||
]);
|
||||
|
||||
// 7. 成功终态
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Project, Task, ReviewVerdict } from '../model/types.js';
|
||||
import type { Project, Task, ReviewVerdict, UsageTokens } from '../model/types.js';
|
||||
|
||||
/** 数据根目录(与 worktree.ts 同约定):<MAESTRO_DATA_DIR 或 ~/.maestro> */
|
||||
function dataDir(): string {
|
||||
@@ -128,6 +128,8 @@ export type OutboxRecord =
|
||||
}>;
|
||||
transcriptRef: string | null; sessionId: string | null;
|
||||
}
|
||||
// token 用量(任意 CC run 结束后发;daemon ingest → setRunUsage 折算成本,再判预算)
|
||||
| { seq: number; at: string; type: 'usage'; usage: UsageTokens; model: string }
|
||||
| { seq: number; at: string; type: 'done' };
|
||||
|
||||
/** OutboxRecord 去掉 seq/at(由 appendOutbox 填) */
|
||||
@@ -138,6 +140,7 @@ export type OutboxPayload =
|
||||
| Omit<Extract<OutboxRecord, { type: 'result' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'spec-result' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'decompose-result' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'usage' }>, 'seq' | 'at'>
|
||||
| Omit<Extract<OutboxRecord, { type: 'done' }>, 'seq' | 'at'>;
|
||||
|
||||
/** 追加一条 outbox 记录(worker 侧调用)。seq = 现有行数+1(worker 单线程,无并发写)。返回写入的完整记录。 */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, Task, ReviewVerdict } from '../model/types.js';
|
||||
import type { Project, Task, ReviewVerdict, UsageTokens } from '../model/types.js';
|
||||
import type { WorktreeInfo } from './worktree.js';
|
||||
import { runClaude } from './cc.js';
|
||||
import { pickModel } from './models.js';
|
||||
@@ -11,6 +11,8 @@ export interface ReviewResult {
|
||||
verdict: ReviewVerdict | null; // 解析不到 = null
|
||||
transcriptRef: string | null;
|
||||
sessionId: string | null;
|
||||
modelUsed?: string;
|
||||
usage?: UsageTokens;
|
||||
}
|
||||
|
||||
/** 复审函数签名(orchestrator 依赖注入点;测试传 mock,生产传 reviewCode/reviewSecurity)。失败时抛错,由 orchestrator 兜底。 */
|
||||
@@ -144,7 +146,7 @@ export async function runReview(
|
||||
if (!cc.finalText.trim()) throw new Error(`${label}未产出最终文本`);
|
||||
|
||||
const { summary, verdict } = parseVerdict(cc.finalText);
|
||||
return { summary, verdict, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId };
|
||||
return { summary, verdict, transcriptRef: cc.transcriptRef, sessionId: cc.sessionId, modelUsed: cc.modelUsed, usage: cc.usage };
|
||||
}
|
||||
|
||||
/** code review 入口(kind=reviewer 的 run) */
|
||||
|
||||
+12
-8
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 模型计价(单源)。成本 = Σ(各类 token × 单价),单价 USD / 每 1M token。
|
||||
// 预算系统据此把 run 的 token 用量折算成精确到项目级的成本。价目随官方调整,集中此处维护。
|
||||
|
||||
export interface UsageTokens {
|
||||
input: number; // 非缓存输入 token
|
||||
output: number; // 输出 token
|
||||
cacheRead: number; // 缓存命中读取 token
|
||||
cacheWrite: number; // 缓存写入(创建)token
|
||||
}
|
||||
|
||||
export const EMPTY_USAGE: UsageTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
|
||||
interface Price { input: number; output: number; cacheRead: number; cacheWrite: number }
|
||||
|
||||
// 单价:USD / 1M token
|
||||
const MODEL_PRICES: Record<string, Price> = {
|
||||
'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
||||
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
||||
};
|
||||
|
||||
// 模型名归一:精确命中优先,否则按已知前缀匹配(容忍日期/区域后缀)
|
||||
function priceOf(model: string): Price | null {
|
||||
if (MODEL_PRICES[model]) return MODEL_PRICES[model];
|
||||
for (const key of Object.keys(MODEL_PRICES)) if (model.startsWith(key)) return MODEL_PRICES[key];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把一个 run 的 token 用量按其模型折算成 USD。未知模型 → 0(不阻断,仅成本记 0)。 */
|
||||
export function computeCost(model: string, u: UsageTokens): number {
|
||||
const p = priceOf(model);
|
||||
if (!p) return 0;
|
||||
const M = 1_000_000;
|
||||
return (u.input * p.input + u.output * p.output + u.cacheRead * p.cacheRead + u.cacheWrite * p.cacheWrite) / M;
|
||||
}
|
||||
|
||||
export function addUsage(a: UsageTokens, b: UsageTokens): UsageTokens {
|
||||
return {
|
||||
input: a.input + b.input, output: a.output + b.output,
|
||||
cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite,
|
||||
};
|
||||
}
|
||||
|
||||
export function totalTokens(u: UsageTokens): number {
|
||||
return u.input + u.output + u.cacheRead + u.cacheWrite;
|
||||
}
|
||||
|
||||
export function isKnownModel(model: string): boolean {
|
||||
return priceOf(model) !== null;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Complexity } from './complexity.js';
|
||||
import type { TaskStatus, GateKind } from './status.js';
|
||||
import type { UsageTokens } from './pricing.js';
|
||||
|
||||
export type { UsageTokens };
|
||||
|
||||
export type Id = string;
|
||||
|
||||
@@ -31,6 +34,9 @@ export interface Project {
|
||||
autoApproveExec?: boolean; // 双复审 approve 后跳过 exec_review 自动合并(默认 false)
|
||||
/** 分项检查命令(JSON,如 {"lint":"npm run lint","typecheck":"npm run typecheck"})*/
|
||||
checks?: string | null;
|
||||
/** 项目级成本预算(按模型×token 折算累计;超额自动暂停)*/
|
||||
budgetUsd?: number | null; // 当期预算上限 USD(null=不限)
|
||||
budgetPeriod?: 'day' | 'month'; // 预算周期(默认 month)
|
||||
}
|
||||
|
||||
export interface ApprovalRecord {
|
||||
@@ -98,6 +104,8 @@ export interface Run {
|
||||
error: string | null;
|
||||
workerPid: number | null; // 执行该 run 的 worker 进程 pid(多进程执行;daemon 写,判活用)
|
||||
lastSeq: number; // 已 ingest 的 outbox 最大 seq(daemon 写,幂等游标;重启续读)
|
||||
usage?: UsageTokens | null; // token 用量(worker 从 SDK 抓取,daemon ingest 落库)
|
||||
costUsd?: number | null; // 该 run 折算成本 USD(按 model × usage,见 model/pricing.ts)
|
||||
}
|
||||
|
||||
export type EventType =
|
||||
@@ -105,6 +113,7 @@ export type EventType =
|
||||
| 'approval.requested' | 'approval.granted' | 'approval.rejected'
|
||||
| 'run.started' | 'run.finished'
|
||||
| 'merge.failed' | 'merge.remediated'
|
||||
| 'budget.exceeded'
|
||||
| 'project.synced';
|
||||
|
||||
export interface Event {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user