feat(usage): 用量明细——/api/usage 加 week 窗口+runs 计数,新增 /api/usage/detail

costSummary 顶层补 runs 计数、period 扩 day|week|month;新增 usageDetail(day|week|month) 返回时间序列分桶 + 分项目下钻(token/花费/任务数/模型,model 维度沿用 usage JSON 近似口径)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-29 17:42:32 +08:00
parent dc7c3030a5
commit 5198435ba0
2 changed files with 86 additions and 5 deletions
+8 -1
View File
@@ -196,12 +196,19 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
// ?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 period = q.period === 'day' ? 'day' : q.period === 'week' ? 'week' : 'month';
const quota = await getUsage();
const cost = store.costSummary(period, q.project && q.project.trim() ? q.project.trim() : undefined);
return { ...(quota ?? {}), cost };
});
// 用量详情:时间序列(按天/按月)+ 分项目下钻,供侧栏「详情」弹框。
app.get('/api/usage/detail', (req) => {
const q = req.query as { granularity?: string };
const granularity = q.granularity === 'month' ? 'month' : q.granularity === 'week' ? 'week' : 'day';
return store.usageDetail(granularity);
});
// 健康检查:daemon 存活 + DB 可读 + 在途 run 数。
app.get('/api/health', () => {
let db = true;
+78 -4
View File
@@ -39,14 +39,22 @@ const SETTINGS_DEFAULT: UserSettings = {
const id = (prefix: string): string => `${prefix}_${nanoid(12)}`;
/** 预算周期起点(UTC):day=今日零点 / month=当月 1 号零点。与存储的 ISO 时间戳同基准比较。 */
function periodStartISO(period: 'day' | 'month'): string {
function periodStartISO(period: 'day' | 'week' | 'month'): string {
const d = new Date();
if (period === 'week') return new Date(Date.now() - 7 * 86400_000).toISOString();
const start = period === 'day'
? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
: Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
return new Date(start).toISOString();
}
/** 把 ISO 时间戳归一到所属周的周一(UTC),返回 YYYY-MM-DD,作为「按周」时间桶标签。 */
function weekStartLabel(iso: string): string {
const d = new Date(iso);
const dow = (d.getUTCDay() + 6) % 7; // 周一=0 … 周日=6
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - dow)).toISOString().slice(0, 10);
}
export class StoreError extends Error {}
const MODEL_ROLES = ['executor', 'planner', 'reviewer', 'conflict'] as const;
@@ -101,12 +109,24 @@ export interface PatchTaskInput {
}
/** 成本明细(按项目 / 按模型 / 总计),供 GET /api/usage 与预算判定 */
export interface CostSummary {
period: 'day' | 'month';
period: 'day' | 'week' | 'month';
total: number;
runs: number;
byProject: Array<{ projectId: string; costUsd: number; tokens: number }>;
byModel: Array<{ model: string; costUsd: number; tokens: number }>;
}
/** 用量详情:时间序列 + 分项目下钻,供 GET /api/usage/detail(详情弹框) */
export interface UsageDetail {
granularity: 'day' | 'week' | 'month';
total: { costUsd: number; tokens: number; runs: number; tasks: number };
series: Array<{ bucket: string; costUsd: number; tokens: number; runs: number }>;
byProject: Array<{
projectId: string; costUsd: number; tokens: number; runs: number; tasks: number;
models: Array<{ model: string; costUsd: number; tokens: number }>;
}>;
}
/** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */
export interface ActiveRun {
runId: string; taskId: string; taskTitle: string; kind: string;
@@ -1157,7 +1177,7 @@ export class Store {
}
/** 成本明细:按项目 / 按模型 / 总计(period 窗口内,可选限定项目)。 */
costSummary(period: 'day' | 'month', projectId?: string): CostSummary {
costSummary(period: 'day' | 'week' | '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
@@ -1176,12 +1196,66 @@ export class Store {
const bm = byModel.get(model) ?? { cost: 0, tokens: 0 }; bm.cost += row.cost; bm.tokens += tokens; byModel.set(model, bm);
}
return {
period, total,
period, total, runs: rows.length,
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 })),
};
}
/**
* 用量详情:时间序列(按天/按周/按月分桶)+ 分项目下钻(token/花费/任务数/模型)。
* day → 近 30 天、桶=YYYY-MM-DDweek → 近 12 周、桶=周一 YYYY-MM-DDmonth → 近 12 个月、桶=YYYY-MM。
* token/model 解析与 costSummary 同款(usage JSON 存累计 token + 最近 modelmodel 维度为近似)。
*/
usageDetail(granularity: 'day' | 'week' | 'month'): UsageDetail {
const now = new Date();
const since = granularity === 'day'
? new Date(Date.now() - 30 * 86400_000).toISOString()
: granularity === 'week'
? new Date(Date.now() - 12 * 7 * 86400_000).toISOString()
: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)).toISOString();
const rows = this.db.prepare(
`SELECT t.project_id AS pid, r.task_id AS tid, r.started_at AS at, 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`,
).all(since) as Array<{ pid: string; tid: string; at: string; usage: string | null; cost: number }>;
const series = new Map<string, { cost: number; tokens: number; runs: number }>();
const proj = new Map<string, { cost: number; tokens: number; runs: number; tasks: Set<string>; models: Map<string, { cost: number; tokens: number }> }>();
const total = { costUsd: 0, tokens: 0, runs: 0, tasks: new Set<string>() };
for (const row of rows) {
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 bucket = granularity === 'day' ? row.at.slice(0, 10)
: granularity === 'week' ? weekStartLabel(row.at)
: row.at.slice(0, 7);
const s = series.get(bucket) ?? { cost: 0, tokens: 0, runs: 0 };
s.cost += row.cost; s.tokens += tokens; s.runs += 1; series.set(bucket, s);
const p = proj.get(row.pid) ?? { cost: 0, tokens: 0, runs: 0, tasks: new Set<string>(), models: new Map() };
p.cost += row.cost; p.tokens += tokens; p.runs += 1; p.tasks.add(row.tid);
const m = p.models.get(model) ?? { cost: 0, tokens: 0 }; m.cost += row.cost; m.tokens += tokens; p.models.set(model, m);
proj.set(row.pid, p);
total.costUsd += row.cost; total.tokens += tokens; total.runs += 1; total.tasks.add(row.tid);
}
return {
granularity,
total: { costUsd: total.costUsd, tokens: total.tokens, runs: total.runs, tasks: total.tasks.size },
series: [...series].sort((a, b) => a[0] < b[0] ? -1 : 1)
.map(([bucket, v]) => ({ bucket, costUsd: v.cost, tokens: v.tokens, runs: v.runs })),
byProject: [...proj].map(([projectId, v]) => ({
projectId, costUsd: v.cost, tokens: v.tokens, runs: v.runs, tasks: v.tasks.size,
models: [...v.models].map(([model, mv]) => ({ model, costUsd: mv.cost, tokens: mv.tokens }))
.sort((a, b) => b.costUsd - a.costUsd),
})).sort((a, b) => b.costUsd - a.costUsd),
};
}
/**
* 预算护栏:项目当期成本达到预算上限时,置 paused 并广播 budget.exceeded(仅在 active→paused 时广播一次)。
* 无预算(budgetUsd 为 null/≤0)则不干预。返回是否已超额(供编排器跳过领取)。