Skip to content

Commit 4aab674

Browse files
Add enforced monthly spending limit with Spending tab and Overview meter
- Settings gains a Spending tab mirroring the dashboard: monthly meter with editable limit, this-month / all-time / avg-per-run stats, and cost by model/provider - Overview shows a Cursor-style "$spent / $limit" progress bar - Default monthly cap is $50 when unset, and it's actually enforced: runs (chat + `coderouter run`) and loop start/resume are blocked once this month's account-wide spend reaches the cap (SpendingLimitError) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9c53404 commit 4aab674

7 files changed

Lines changed: 262 additions & 4 deletions

File tree

packages/app/src/pages/Overview.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import React, { useEffect, useState } from 'react';
2-
import { api, type UsageReport } from '../lib/api';
2+
import { api, type SettingsReport, type UsageReport } from '../lib/api';
33
import { Heatmap } from '../components/Heatmap';
44
import { Section, Spinner, money } from '../components/common';
5+
import { DEFAULT_LIMIT_USD, SpendingProgress } from './Spending';
56

67
export function OverviewPage(): React.ReactElement {
78
const [data, setData] = useState<UsageReport | null>(null);
9+
const [settings, setSettings] = useState<SettingsReport | null>(null);
810
useEffect(() => {
911
void api.usage().then(setData).catch(() => {});
12+
void api.settings().then(setSettings).catch(() => {});
1013
}, []);
1114
if (!data) return <Spinner />;
1215
const t = data.totals;
1316
const h = data.highlights;
17+
const limit = settings?.limits.monthlyUsd ?? DEFAULT_LIMIT_USD;
1418

1519
return (
1620
<div>
@@ -21,6 +25,19 @@ export function OverviewPage(): React.ReactElement {
2125
<Big label="Projects" value={String(data.project.projectCount)} />
2226
</div>
2327

28+
<Section title="Monthly spend">
29+
<div className="card">
30+
<div className="mb-3 flex items-baseline justify-between">
31+
<span className="text-sm text-muted">This month</span>
32+
<span className="text-sm">
33+
<span className="font-semibold">{money(t.monthCostUsd)}</span>
34+
<span className="text-muted"> / {money(limit)}</span>
35+
</span>
36+
</div>
37+
<SpendingProgress spent={t.monthCostUsd} limit={limit} />
38+
</div>
39+
</Section>
40+
2441
<Section title="Activity">
2542
<div className="card">
2643
<Heatmap days={data.heatmap} />

packages/app/src/pages/SettingsArea.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ArrowLeft } from 'lucide-react';
33
import { Tabs } from '../components/common';
44
import { SettingsPage } from './Settings';
55
import { ModelsPage } from './Models';
6+
import { SpendingPage } from './Spending';
67

78
/** Settings hub: provider/host configuration plus model tier preferences. */
89
export function SettingsArea({ onBack }: { onBack?: () => void }): React.ReactElement {
@@ -22,11 +23,12 @@ export function SettingsArea({ onBack }: { onBack?: () => void }): React.ReactEl
2223
tabs={[
2324
{ id: 'general', label: 'General' },
2425
{ id: 'models', label: 'Models' },
26+
{ id: 'spending', label: 'Spending' },
2527
]}
2628
active={tab}
2729
onChange={setTab}
2830
/>
29-
{tab === 'general' ? <SettingsPage /> : <ModelsPage />}
31+
{tab === 'general' ? <SettingsPage /> : tab === 'models' ? <ModelsPage /> : <SpendingPage />}
3032
</div>
3133
);
3234
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { api, type SettingsReport, type UsageReport } from '../lib/api';
3+
import { Section, Spinner, cls, money } from '../components/common';
4+
5+
/** Default cap mirrored from the daemon (`DEFAULT_MONTHLY_LIMIT_USD`). */
6+
export const DEFAULT_LIMIT_USD = 50;
7+
8+
/** A Cursor-style spend-vs-limit progress bar. */
9+
export function SpendingProgress({
10+
spent,
11+
limit,
12+
}: {
13+
spent: number;
14+
limit: number;
15+
}): React.ReactElement {
16+
const pct = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
17+
const over = spent >= limit;
18+
return (
19+
<div>
20+
<div className="h-2 w-full overflow-hidden rounded-full bg-panel2">
21+
<div
22+
className={cls('h-full rounded-full transition-all', over ? 'bg-bad' : 'bg-accent')}
23+
style={{ width: `${Math.max(2, pct)}%` }}
24+
/>
25+
</div>
26+
<div className="mt-1.5 flex items-center justify-between text-xs">
27+
<span className={cls(over ? 'text-bad' : 'text-muted')}>
28+
{over ? 'Monthly limit reached' : `${money(Math.max(0, limit - spent))} remaining this month`}
29+
</span>
30+
<span className="text-muted">{Math.round(pct)}%</span>
31+
</div>
32+
</div>
33+
);
34+
}
35+
36+
export function SpendingPage(): React.ReactElement {
37+
const [usage, setUsage] = useState<UsageReport | null>(null);
38+
const [settings, setSettings] = useState<SettingsReport | null>(null);
39+
const [draft, setDraft] = useState('');
40+
const [saving, setSaving] = useState(false);
41+
42+
const load = (): void => {
43+
void api.usage().then(setUsage).catch(() => {});
44+
void api
45+
.settings()
46+
.then((s) => {
47+
setSettings(s);
48+
setDraft(String(s.limits.monthlyUsd ?? DEFAULT_LIMIT_USD));
49+
})
50+
.catch(() => {});
51+
};
52+
useEffect(load, []);
53+
54+
if (!usage || !settings) return <Spinner />;
55+
56+
const t = usage.totals;
57+
const limit = settings.limits.monthlyUsd ?? DEFAULT_LIMIT_USD;
58+
const spent = t.monthCostUsd;
59+
const avgPerRun = t.runs > 0 ? t.costUsd / t.runs : 0;
60+
const maxProvider = Math.max(1, ...usage.byProvider.map((b) => b.costUsd));
61+
62+
const save = async (): Promise<void> => {
63+
const val = Number(draft);
64+
setSaving(true);
65+
try {
66+
await api.setLimit(Number.isFinite(val) && val > 0 ? val : null);
67+
load();
68+
} finally {
69+
setSaving(false);
70+
}
71+
};
72+
73+
return (
74+
<div>
75+
<div className="mb-5 flex items-baseline justify-between">
76+
<h1 className="text-xl font-semibold">Spending</h1>
77+
<span className="text-sm text-muted">{money(t.costUsd)} all-time</span>
78+
</div>
79+
80+
{/* Monthly meter + limit editor */}
81+
<div className="card mb-4">
82+
<div className="flex flex-wrap items-start justify-between gap-4">
83+
<div>
84+
<div className="text-xs uppercase tracking-wide text-muted">Spending · {monthLabel(t.monthKey)}</div>
85+
<div className="mt-1 text-3xl font-semibold">{money(spent)}</div>
86+
</div>
87+
<div className="flex items-center gap-2">
88+
<span className="text-sm text-muted">Monthly limit $</span>
89+
<input
90+
type="number"
91+
min={0}
92+
step={1}
93+
value={draft}
94+
onChange={(e) => setDraft(e.target.value)}
95+
className="input w-28"
96+
placeholder={String(DEFAULT_LIMIT_USD)}
97+
/>
98+
<button onClick={() => void save()} disabled={saving} className="btn btn-primary">
99+
{saving ? 'Saving…' : 'Save'}
100+
</button>
101+
</div>
102+
</div>
103+
<div className="mt-4">
104+
<SpendingProgress spent={spent} limit={limit} />
105+
</div>
106+
</div>
107+
108+
{/* Stat cards */}
109+
<div className="mb-6 grid grid-cols-1 gap-3 md:grid-cols-3">
110+
<Stat label="This month" value={money(spent)} hint={monthLabel(t.monthKey)} />
111+
<Stat label="All-time cost" value={money(t.costUsd)} hint={`${t.runs} runs`} />
112+
<Stat label="Avg cost / run" value={money(avgPerRun)} hint="across all routes" />
113+
</div>
114+
115+
<Section title="Cost by model / provider">
116+
<div className="card space-y-3">
117+
{usage.byProvider.length === 0 && <div className="text-sm text-muted">No spend recorded yet.</div>}
118+
{usage.byProvider.map((b) => (
119+
<div key={b.key}>
120+
<div className="mb-1 flex items-center justify-between text-sm">
121+
<span className="min-w-0 truncate">{b.label}</span>
122+
<span className="shrink-0 text-muted">{money(b.costUsd)}</span>
123+
</div>
124+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-panel2">
125+
<div className="h-full rounded-full bg-accent" style={{ width: `${(b.costUsd / maxProvider) * 100}%` }} />
126+
</div>
127+
</div>
128+
))}
129+
</div>
130+
</Section>
131+
</div>
132+
);
133+
}
134+
135+
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }): React.ReactElement {
136+
return (
137+
<div className="card">
138+
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
139+
<div className="mt-1 text-2xl font-semibold">{value}</div>
140+
{hint && <div className="mt-0.5 text-xs text-muted">{hint}</div>}
141+
</div>
142+
);
143+
}
144+
145+
/** `YYYY-MM` → `June 2026`. */
146+
function monthLabel(monthKey: string): string {
147+
const [y, m] = monthKey.split('-').map(Number);
148+
if (!y || !m) return monthKey;
149+
return new Date(y, m - 1, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
150+
}

packages/cli/src/daemon/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from './lockfile.js';
1717
import { handlePtyUpgrade } from './pty.js';
1818
import { getSupervisor } from './supervisor.js';
19+
import { assertWithinSpendingLimit } from '../spend.js';
1920

2021
/**
2122
* CodeRouter daemon ("app-server").
@@ -481,6 +482,7 @@ async function handleLoops(
481482
if (method === 'POST') {
482483
switch (action) {
483484
case 'start':
485+
await assertWithinSpendingLimit(project);
484486
await sup.start(project, loopId).catch((e) => {
485487
throw e;
486488
});
@@ -489,6 +491,7 @@ async function handleLoops(
489491
sup.pause(loopId);
490492
return reply(res, 200, { ok: true });
491493
case 'resume':
494+
await assertWithinSpendingLimit(project);
492495
await sup.resume(project, loopId);
493496
return reply(res, 200, { ok: true });
494497
case 'stop':

packages/cli/src/runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
import type { Report } from '@coderouter/core';
3030
import { spinnerProgress } from './ui/progress.js';
3131
import { getPreferredModels } from './ui/setup.js';
32+
import { assertWithinSpendingLimit } from './spend.js';
3233

3334
export type ProgressAdapter = {
3435
notifier: ProgressNotifier;
@@ -166,6 +167,9 @@ export async function executeRun(opts: CliRunOpts): Promise<{
166167
output: ModeOutput;
167168
store: Store;
168169
}> {
170+
// Enforce the monthly spending cap before doing any billable work.
171+
await assertWithinSpendingLimit(opts.cwd);
172+
169173
const { registry, router, store } = await buildExecutionEnv(opts.cwd);
170174
// Stable conversation id: the REPL passes one per session so turns
171175
// group into a single browsable chat; one-shot runs get a fresh id.

packages/cli/src/spend.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { existsSync } from 'node:fs';
2+
import { listProjects, openStore, resolveDbPath } from '@coderouter/core';
3+
import { getEffectiveSpendingLimit } from './ui/setup.js';
4+
5+
/** `YYYY-MM` key for a timestamp in local time (matches the dashboard). */
6+
function localMonthKey(ts: number): string {
7+
const d = new Date(ts);
8+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
9+
}
10+
11+
/**
12+
* Total spend (USD) for the current calendar month across every CodeRouter
13+
* project registered on this machine. Spending is account-wide because API
14+
* keys and billing are account-wide, so the cap is enforced globally.
15+
*/
16+
export async function getMonthlySpendUsd(extraCwd?: string): Promise<number> {
17+
const dbPaths = new Set<string>();
18+
for (const p of listProjects()) dbPaths.add(p.dbPath);
19+
if (extraCwd) {
20+
const dp = resolveDbPath(extraCwd);
21+
if (existsSync(dp)) dbPaths.add(dp);
22+
}
23+
24+
const monthKey = localMonthKey(Date.now());
25+
let total = 0;
26+
for (const path of dbPaths) {
27+
if (!existsSync(path)) continue;
28+
const store = await openStore(path);
29+
try {
30+
for (const r of store.runs.list(5000)) {
31+
if (localMonthKey(r.createdAt) === monthKey) total += r.costUsd;
32+
}
33+
} finally {
34+
try {
35+
store.db.close();
36+
} catch {
37+
/* best-effort */
38+
}
39+
}
40+
}
41+
return total;
42+
}
43+
44+
/** Thrown when a run is blocked because the monthly spending cap is reached. */
45+
export class SpendingLimitError extends Error {
46+
constructor(
47+
public spentUsd: number,
48+
public limitUsd: number,
49+
) {
50+
super(
51+
`Monthly spending limit reached: $${spentUsd.toFixed(2)} of $${limitUsd.toFixed(2)} used this month. ` +
52+
`Raise or remove the cap in Settings → Spending to continue.`,
53+
);
54+
this.name = 'SpendingLimitError';
55+
}
56+
}
57+
58+
/**
59+
* Throw `SpendingLimitError` if this month's spend already meets or exceeds
60+
* the effective monthly cap. Call before starting any billable work.
61+
*/
62+
export async function assertWithinSpendingLimit(cwd?: string): Promise<void> {
63+
const limit = getEffectiveSpendingLimit();
64+
const spent = await getMonthlySpendUsd(cwd);
65+
if (spent >= limit) throw new SpendingLimitError(spent, limit);
66+
}

packages/cli/src/ui/setup.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,16 @@ export function setHostEnabled(provider: HostProvider, enabled: boolean): void {
211211
else process.env[envName] = '1';
212212
}
213213

214+
/**
215+
* Default monthly spending cap (USD) enforced when the user hasn't set one.
216+
* CodeRouter always has a cap so runaway agent loops can't quietly burn
217+
* through an API budget.
218+
*/
219+
export const DEFAULT_MONTHLY_LIMIT_USD = 50;
220+
214221
/**
215222
* Read the persisted monthly spending limit (USD). Returns `null` when
216-
* unset or invalid, meaning "no cap".
223+
* unset or invalid, meaning "use the default cap".
217224
*/
218225
export function getSpendingLimit(): { monthlyUsd: number | null } {
219226
try {
@@ -225,9 +232,18 @@ export function getSpendingLimit(): { monthlyUsd: number | null } {
225232
}
226233
}
227234

235+
/**
236+
* The monthly limit actually enforced: the user's value if set, else the
237+
* default cap. Always a positive number — there is always a cap.
238+
*/
239+
export function getEffectiveSpendingLimit(): number {
240+
const v = getSpendingLimit().monthlyUsd;
241+
return typeof v === 'number' && v > 0 ? v : DEFAULT_MONTHLY_LIMIT_USD;
242+
}
243+
228244
/**
229245
* Persist (or clear) the monthly spending limit. Passing `null` or a
230-
* non-positive number removes the cap.
246+
* non-positive number removes the explicit cap (falling back to the default).
231247
*/
232248
export function setSpendingLimit(monthlyUsd: number | null): void {
233249
let existing: CredentialsFile = {};

0 commit comments

Comments
 (0)