Commit

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays

- Comped accounts get a monthly budget at cost (default $150), alerts at 50/75/90/100%, and new runs wait at 100% until staff raise it. - A platform-wide daily breaker (default $75 at cost) pauses hosted-model runs g1t would pay for, emails staff, and can be lifted for the day in sudo. - sudo Costs shows g1t's own spend by bucket against money in.

syntaqxcommitted Parentb57976cBrowse files
22 files+1294−160/22 viewed
+11−2
6969 return [
7070 ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
7171 ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
72− ["Limit", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
72+ [
73+ "Limit",
74+ terms.kind === "comped"
75+ ? terms.ceilingMicros == null
76+ ? "The default monthly budget, at cost"
77+ : `${usd(terms.ceilingMicros)} a month, at cost`
78+ : terms.ceilingMicros == null
79+ ? "By trust"
80+ : usd(terms.ceilingMicros),
81+ ],
7382 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
7483 ["Note", terms.note || "—"],
7584 ];
242251 <Field label="Discount %" hint="Custom only.">
243252 <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
244253 </Field>
245− <Field label="Limit $" hint="Unpaid usage allowed. Blank: trust decides.">
254+ <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. Comped: the monthly budget at cost; blank: the default ($150).">
246255 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
247256 </Field>
248257 <Field label="Until" hint="Blank: no end. UTC.">
+39−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import type { CostDay } from "@g1t/contracts";
4+import type { CostDay, SpendCaps } from "@g1t/contracts";
55
66 import {
77 daySeries,
1313 parseMapping,
1414 parseRange,
1515 percentLabel,
16+ spendBanner,
17+ spendRows,
1618 unitDollars,
1719 } from "./costs.ts";
1820
112114 assert.equal(parseMapping(form({ product: "r2", meter: "*", bucket: "" })).ok, false);
113115 assert.equal(parseMapping(form({ product: "r2", meter: "*", bucket: "git", driftPercent: "-1" })).ok, false);
114116 });
117+
118+const caps = (over: Partial<SpendCaps> = {}): SpendCaps => ({
119+ day: "2026-10-06",
120+ month: "2026-10",
121+ todayMicros: 20_000_000,
122+ dailyCapMicros: 75_000_000,
123+ tripped: false,
124+ trippedAt: null,
125+ liftedBy: null,
126+ liftedAt: null,
127+ liftNote: null,
128+ monthBuckets: [
129+ { bucket: "comped", title: "Comped (g1t's own)", micros: 40_000_000 },
130+ { bucket: "trial", title: "Trial pool", micros: 5_000_000 },
131+ ],
132+ comped: [{ account: "ws_flagon-io", name: "flagon-io", usedMicros: 40_000_000, ceilingMicros: 150_000_000, defaultCeiling: true, level: 0 }],
133+ freeTierMicros: 1_000_000,
134+ fixedMonthlyMicros: 30_000_000,
135+ revenueMicros: 0,
136+ ...over,
137+});
138+
139+test("the spend bar shows only when a cap stops work", () => {
140+ assert.equal(spendBanner(caps()), null);
141+ assert.ok((spendBanner(caps({ tripped: true, todayMicros: 80_000_000 })) ?? "").startsWith("The daily breaker is open ($80.00 of $75.00 today)"));
142+ const usedUp = caps({ comped: [{ account: "ws_flagon-io", name: "flagon-io", usedMicros: 150_000_000, ceilingMicros: 150_000_000, defaultCeiling: true, level: 100 }] });
143+ assert.ok((spendBanner(usedUp) ?? "").startsWith("Flagon-io used its $150.00 monthly budget"));
144+ // No budget set to zero is ever "used up".
145+ assert.equal(spendBanner(caps({ comped: [{ account: "a", name: "a", usedMicros: 9, ceilingMicros: 0, defaultCeiling: false, level: 0 }] })), null);
146+});
147+
148+test("what g1t paid this month adds every bucket, the free tier and subscriptions", () => {
149+ const { rows, totalMicros } = spendRows(caps());
150+ assert.deepEqual(rows.map((r) => r.key), ["comped", "trial", "free", "fixed"]);
151+ assert.equal(totalMicros, 76_000_000);
152+});
+46−2
22 * Costs & margin: the arithmetic behind the page, apart from the SVG and
33 * the Workers runtime so it can be tested under Node. Money is in micros.
44 */
5−import type { CostDay, CostMappingInput, CostSettings } from "@g1t/contracts";
5+import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts";
66
7−import { parseDollars } from "./money.ts";
7+import { parseDollars, usd } from "./money.ts";
88
99 /** Buckets Cloudflare does not bill: their cost is g1t's own figure. */
1010 export const NOT_CLOUDFLARE = new Set(["models"]);
164164 },
165165 };
166166 }
167+
168+// --- g1t's own spend (billing's budget) ---------------------------------------
169+
170+/**
171+ * The red bar on every sudo page: the daily breaker open, or a comped
172+ * account's monthly budget used up. Null when neither.
173+ */
174+export function spendBanner(caps: SpendCaps): string | null {
175+ const parts: string[] = [];
176+ if (caps.tripped) {
177+ parts.push(
178+ `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new hosted-model agent runs g1t pays for wait until 00:00 UTC`,
179+ );
180+ }
181+ for (const budget of caps.comped) {
182+ if (budget.ceilingMicros > 0 && budget.usedMicros >= budget.ceilingMicros) {
183+ parts.push(`${budget.name} used its ${usd(budget.ceilingMicros)} monthly budget, so new work on it is refused`);
184+ }
185+ }
186+ if (parts.length === 0) return null;
187+ const text = parts.join("; and ");
188+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
189+}
190+
191+/** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */
192+export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } {
193+ const notes: Record<string, string> = {
194+ comped: "Work on comped accounts, at cost",
195+ trial: "Trial credit, at cost",
196+ oss: "Checks and workflows on public repositories, at cost",
197+ given: "Free workspaces' overruns past their trial",
198+ unpaid: "Charged, but no real money yet (test-mode payments)",
199+ };
200+ const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" }));
201+ rows.push({ key: "free", title: "Free tier", micros: caps.freeTierMicros, note: "Free workspaces' share of git, storage and platform, reconciled through yesterday" });
202+ rows.push({ key: "fixed", title: "Cloudflare subscriptions", micros: caps.fixedMonthlyMicros, note: "A month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)" });
203+ return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) };
204+}
205+
206+/** How far a cap is used, 0 to 100, for a meter. */
207+export function capPercent(usedMicros: number, capMicros: number): number {
208+ if (capMicros <= 0) return 0;
209+ return Math.max(0, Math.min(100, (usedMicros / capMicros) * 100));
210+}
+15−2
77 import { MobileBar, Sidebar } from "./components/shell";
88 import { ButtonLink } from "./components/ui";
99 import type { NavCounts } from "./lib/nav";
10+import { spendBanner } from "./lib/costs";
1011 import { admin, identity, statusAdmin } from "./lib/services.server";
1112 import { settle } from "./lib/settle";
1213 import { requireStaff, zoneContext } from "./lib/staff";
2627 export async function loader({ context }: Route.LoaderArgs) {
2728 const { email } = requireStaff(context);
2829 // The sidebar's counts: a service that does not answer shows none.
29− const [waitlist, incidents, alerts] = await Promise.all([
30+ const [waitlist, incidents, alerts, caps] = await Promise.all([
3031 settle(identity.waitlistPending()),
3132 settle(statusAdmin.openCount()),
3233 settle(admin.costAlerts()),
34+ settle(admin.spendCaps()),
3335 ]);
3436 const counts: NavCounts = { waitlist: waitlist.ok ? waitlist.value : 0, incidents: incidents.ok ? incidents.value : 0 };
3537 // Every page says times in this zone (components/ui.tsx `When`).
3941 const margin = alerts.ok
4042 ? alerts.value.filter((alert) => rank.includes(alert.kind)).sort((a, b) => rank.indexOf(a.kind) - rank.indexOf(b.kind))
4143 : [];
42− return { email, counts, zone, zoneChosen: chosen, margin };
44+ // g1t's own spend (billing's budget): the daily breaker open, or a comped
45+ // account's monthly budget used up. Red until it clears or staff act.
46+ const spend = caps.ok ? spendBanner(caps.value) : null;
47+ return { email, counts, zone, zoneChosen: chosen, margin, spend };
4348 }
4449
4550 export function Layout({ children }: { children: React.ReactNode }) {
6671 </a>
6772 </div>
6873 )}
74+ {root?.spend && (
75+ <div role="alert" className="border-b border-danger/40 bg-danger/12 px-4 py-2 text-sm text-danger">
76+ <span className="font-medium">Spend cap:</span> {root.spend}{" "}
77+ <a href="/costs#spend" className="underline underline-offset-2">
78+ g1t's own spend
79+ </a>
80+ </div>
81+ )}
6982 {children}
7083 </div>
7184 {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */}
+141−0
1+import type { ReactNode } from "react";
12 import { Link, data, redirect } from "react-router";
23
34 import type { CostsReport, PriceProposal } from "@g1t/contracts";
67 import { DaysChart } from "~/components/costs";
78 import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Stat, When } from "~/components/ui";
89 import {
10+ capPercent,
911 countLabel,
1012 daySeries,
1113 driftLabel,
1517 parseMapping,
1618 parseRange,
1719 percentLabel,
20+ spendRows,
1821 unitDollars,
1922 } from "~/lib/costs";
2023 import { dollarsField, usd } from "~/lib/money";
3134 settings: "Guardrails saved. They apply from the next run.",
3235 mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
3336 removed: "Mapping removed.",
37+ lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.",
3438 };
3539
3640 const RANGES = [7, 30, 90];
7074 if (run.value.value.problems.length > 0) return fail("run", run.value.value.problems.join(" "));
7175 throw back("run");
7276 }
77+ if (intent === "lift") {
78+ const note = String(form.get("note") ?? "").trim().slice(0, 500);
79+ if (note.length < 5) return fail("lift", "Say why it is lifted, for whoever looks next.");
80+ const result = await settle(admin.liftBreaker(note, staff.email));
81+ if (!result.ok) return fail("lift", `Billing did not answer: ${result.error}`);
82+ if (!result.value.ok) return fail("lift", result.value.error.message);
83+ throw back("lifted", "#spend");
84+ }
7385 if (intent === "decide") {
7486 const id = String(form.get("id") ?? "");
7587 const decision = form.get("decision") === "approve" ? "approve" : "reject";
202214 />
203215 </div>
204216
217+ <SpendSection caps={report.caps} error={failed?.section === "lift" ? failed.error : null} />
218+
205219 <Section
206220 className="mt-6"
207221 title={product ? `${product.title}, by day` : "By day"}
603617 );
604618 }
605619
620+function CapMeter({ label, used, cap, hint }: { label: string; used: number; cap: number; hint: ReactNode }) {
621+ const percent = capPercent(used, cap);
622+ const tone = cap > 0 && used >= cap ? "bg-danger" : percent >= 75 ? "bg-warn" : "bg-merged";
623+ return (
624+ <div className="rounded-md border border-line px-4 py-3">
625+ <div className="flex flex-wrap items-baseline justify-between gap-2">
626+ <span className="text-sm font-medium">{label}</span>
627+ <span className="tabular text-sm">
628+ {usd(used)} <span className="text-faint">of {cap > 0 ? usd(cap) : "no cap"}</span>
629+ </span>
630+ </div>
631+ <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(percent)} aria-label={label}>
632+ <div className={`h-full ${tone}`} style={{ width: `${percent}%` }} />
633+ </div>
634+ <p className="mt-1.5 text-xs text-muted">{hint}</p>
635+ </div>
636+ );
637+}
638+
639+/** g1t's own spend: the daily breaker, comped budgets, and this month by what paid. */
640+function SpendSection({ caps, error }: { caps: CostsReport["caps"]; error: string | null }) {
641+ const { rows, totalMicros } = spendRows(caps);
642+ const net = caps.revenueMicros - totalMicros;
643+ return (
644+ <Section
645+ className="mt-6"
646+ id="spend"
647+ title="g1t's own spend"
648+ description="What g1t pays for itself, at cost: comped accounts, the trial and open-source pools, free workspaces' overruns, and anything charged without real money behind it. Two caps hold it: each comped account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for."
649+ >
650+ <div className="grid gap-3 lg:grid-cols-2">
651+ <CapMeter
652+ label={`Today, ${caps.day} (UTC)`}
653+ used={caps.todayMicros}
654+ cap={caps.dailyCapMicros}
655+ hint={
656+ caps.tripped ? (
657+ <span className="text-danger">
658+ Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new hosted-model runs g1t pays for wait until 00:00 UTC.
659+ </span>
660+ ) : caps.liftedBy ? (
661+ <>
662+ Lifted for today by {caps.liftedBy}
663+ {caps.liftNote ? `: “${caps.liftNote}”` : ""}.
664+ </>
665+ ) : (
666+ "Paying workspaces on a live card are never paused. PLATFORM_DAILY_SPEND_CAP_MICROS."
667+ )
668+ }
669+ />
670+ {caps.comped.map((b) => (
671+ <CapMeter
672+ key={b.account}
673+ label={`${b.name}, ${caps.month} (comped)`}
674+ used={b.usedMicros}
675+ cap={b.ceilingMicros}
676+ hint={
677+ <>
678+ {b.ceilingMicros > 0 && b.usedMicros >= b.ceilingMicros ? (
679+ <span className="text-danger">Used up: new work on it is refused. </span>
680+ ) : null}
681+ {b.defaultCeiling ? "The default budget (COMPED_MONTHLY_CEILING_MICROS)" : "Its own budget, in its terms"}. Raise it on{" "}
682+ <Link to={`/workspaces/${encodeURIComponent(b.name)}#billing`} className="underline underline-offset-2">
683+ its account
684+ </Link>
685+ : Terms, Limit.
686+ </>
687+ }
688+ />
689+ ))}
690+ </div>
691+ {(caps.tripped || error) && (
692+ <form method="post" action="#spend" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
693+ <input type="hidden" name="intent" value="lift" />
694+ <Field label="Why lift it" hint="Recorded in the audit log.">
695+ <Input name="note" required minLength={5} maxLength={500} placeholder="e.g. Launch day; watching it" className="sm:min-w-[24rem]" />
696+ </Field>
697+ <Button type="submit" variant="danger">
698+ Lift for today
699+ </Button>
700+ </form>
701+ )}
702+ {error && (
703+ <div className="mt-3">
704+ <Notice tone="error">{error}</Notice>
705+ </div>
706+ )}
707+ <div className="-mx-4 mt-5 overflow-x-auto sm:-mx-5">
708+ <table className="w-full min-w-[34rem] text-sm">
709+ <thead>
710+ <tr className="border-b border-line text-left text-xs text-muted">
711+ <th className="px-4 py-2 font-medium sm:px-5">{caps.month}, so far</th>
712+ <th className="px-4 py-2 text-right font-medium sm:pr-5">g1t paid</th>
713+ </tr>
714+ </thead>
715+ <tbody>
716+ {rows.map((row) => (
717+ <tr key={row.key} className="border-b border-line align-top">
718+ <td className="px-4 py-2.5 sm:px-5">
719+ {row.title}
720+ <span className="block text-xs text-faint">{row.note}</span>
721+ </td>
722+ <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(row.micros)}</td>
723+ </tr>
724+ ))}
725+ <tr className="border-b border-line font-medium">
726+ <td className="px-4 py-2.5 sm:px-5">All of it</td>
727+ <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(totalMicros)}</td>
728+ </tr>
729+ <tr className="border-b border-line">
730+ <td className="px-4 py-2.5 sm:px-5">
731+ Money in
732+ <span className="block text-xs text-faint">Usage paid for and the plan, reconciled through yesterday</span>
733+ </td>
734+ <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(caps.revenueMicros)}</td>
735+ </tr>
736+ <tr>
737+ <td className="px-4 py-2.5 sm:px-5">Money in less what g1t paid</td>
738+ <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${net < 0 ? "text-danger" : "text-fg-soft"}`}>{usd(net, { signed: true })}</td>
739+ </tr>
740+ </tbody>
741+ </table>
742+ </div>
743+ </Section>
744+ );
745+}
746+
606747 function MappingsTable({ report }: { report: CostsReport }) {
607748 return (
608749 <div className="-mx-4 overflow-x-auto sm:-mx-5">
+81−0
10611061 /// whole time cap.
10621062 #[serde(alias = "estimate_micros")]
10631063 pub estimate_micros: i64,
1064+ /// An agent run on g1t's hosted models (not the workspace's own
1065+ /// provider). Unsaid, an agent run is taken to be one. g1t's daily
1066+ /// spend breaker pauses these when g1t is paying for them.
1067+ #[serde(default, alias = "hosted_model")]
1068+ pub hosted_model: Option<bool>,
10641069 }
10651070
10661071 #[derive(Clone, Debug, Serialize, Deserialize)]
24332438 pub lines: Vec<CostLineSummary>,
24342439 pub mappings: Vec<CostMapping>,
24352440 pub settings: CostSettings,
2441+ /// g1t's own spend against its two caps.
2442+ #[serde(default)]
2443+ pub caps: SpendCaps,
2444+}
2445+
2446+/// What g1t itself pays for, against its caps (billing's `budget`): the
2447+/// daily breaker on all of it, and each comped account's monthly budget.
2448+/// At cost, never at price. What sudo's Costs page and its red bar show.
2449+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2450+#[serde(rename_all = "camelCase")]
2451+pub struct SpendCaps {
2452+ /// Today (UTC), YYYY-MM-DD, and this month, YYYY-MM.
2453+ pub day: String,
2454+ pub month: String,
2455+ /// What g1t paid for itself today across every workspace: comped work,
2456+ /// the trial and open-source pools, free workspaces' overruns, and
2457+ /// anything charged without real money behind it.
2458+ pub today_micros: i64,
2459+ /// `PLATFORM_DAILY_SPEND_CAP_MICROS`. Zero: no breaker.
2460+ pub daily_cap_micros: i64,
2461+ /// The breaker is open: new hosted-model agent runs that g1t would pay
2462+ /// for wait until tomorrow (UTC) or until staff lift it.
2463+ pub tripped: bool,
2464+ pub tripped_at: Option<String>,
2465+ /// Staff lifted it for the rest of the day.
2466+ pub lifted_by: Option<String>,
2467+ pub lifted_at: Option<String>,
2468+ pub lift_note: Option<String>,
2469+ /// This month so far, by what paid: `comped`, `trial`, `oss`, `given`,
2470+ /// `unpaid`.
2471+ pub month_buckets: Vec<SpendBucket>,
2472+ /// Each comped account's monthly budget.
2473+ pub comped: Vec<CompedBudget>,
2474+ /// Free workspaces' share of this month's reconciled costs (git,
2475+ /// storage, platform), through yesterday.
2476+ pub free_tier_micros: i64,
2477+ /// `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare subscriptions, an estimate.
2478+ pub fixed_monthly_micros: i64,
2479+ /// Money in this month, through the last reconciled day.
2480+ pub revenue_micros: i64,
2481+}
2482+
2483+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2484+#[serde(rename_all = "camelCase")]
2485+pub struct SpendBucket {
2486+ pub bucket: String,
2487+ pub title: String,
2488+ pub micros: i64,
2489+}
2490+
2491+/// A comped account's monthly budget: what its work cost g1t this month.
2492+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2493+#[serde(rename_all = "camelCase")]
2494+pub struct CompedBudget {
2495+ pub account: String,
2496+ pub name: String,
2497+ pub used_micros: i64,
2498+ /// Zero: no budget.
2499+ pub ceiling_micros: i64,
2500+ /// The ceiling is `COMPED_MONTHLY_CEILING_MICROS`, not the account's own.
2501+ pub default_ceiling: bool,
2502+ /// 50, 75, 90, 100, or 0.
2503+ pub level: u32,
2504+}
2505+
2506+/// `admin_spend_caps`: g1t's own spend against its caps. Returns `SpendCaps`.
2507+#[derive(Debug, Default, Serialize, Deserialize)]
2508+pub struct AdminSpendCapsArgs {}
2509+
2510+/// `admin_lift_breaker`: lets hosted-model runs start again for the rest
2511+/// of today (UTC), with why. Recorded in the audit log. Returns
2512+/// `Outcome<SpendCaps>`.
2513+#[derive(Debug, Serialize, Deserialize)]
2514+pub struct AdminLiftBreakerArgs {
2515+ pub note: String,
2516+ pub by: String,
24362517 }
24372518
24382519 /// `admin_cost_alerts`: the open margin alerts, for sudo's banner.
+74−1
44 prices at cost plus 20%, and tells staff when the margin slips. Internal.
55 Code: `services/billing/src/costs.rs` (reading the bill), `margin.rs`
66 (reconciliation, drift, alerts), `pricing.rs` (versions, proposals,
7−notice), `keeper.rs` (sandbox and Workers for Platforms measurements).
7+notice), `keeper.rs` (sandbox and Workers for Platforms measurements),
8+`budget.rs` (what g1t pays for itself, and its caps; see
9+[Spend caps](#spend-caps)).
810 Page: sudo **Costs & margin** (`/costs`).
911
1012 Several Cloudflare products g1t runs on are new. Artifacts bills
208210 Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one
209211 `ALTER` is applied once by D1's migration tracking. Migration
210212 `0023_one_operation_mapping.sql` drops `billable_units` (see above).
213+
214+## Spend caps
215+
216+Two caps keep what g1t pays for itself bounded while billing takes no
217+real money. Both are measured at **cost** (what Cloudflare and the model
218+providers charge g1t), never at price. Code: `services/billing/src/budget.rs`.
219+Page: sudo **Costs & margin** → **g1t's own spend** (`/costs#spend`).
220+
221+### What counts as g1t's own spend
222+
223+Every charge that settles (an agent run's model cost from `finish_run` or
224+AI Gateway's settlement, sandbox time from `record_sandbox`, a build from
225+`charge_feature`) is split by what paid for it and g1t's part is added to
226+`g1t_spend` (day, bucket, billing account):
227+
228+| Bucket | What |
229+| --- | --- |
230+| `comped` | All of a comped account's work (flagon-io) |
231+| `trial` | The trial credit's share |
232+| `oss` | The open-source pool's share |
233+| `given` | A free workspace's overrun past its last bit of trial |
234+| `unpaid` | Charged, but with no real money behind it: Stripe's test key, or `FREE_WHILE_BUILDING` |
235+
236+The plan's included usage and on-demand charges count as revenue only
237+with live payments; in test mode they are `unpaid`. A workspace's own
238+model provider costs g1t nothing and is not counted. Month-end meters
239+(git, storage, scans, embeddings, the cache) are not counted here; the
240+daily reconciliation covers them. Migration `0024_spend_caps.sql`
241+backfills the current month from the ledger.
242+
243+### Caps
244+
245+| Cap | Variable (g1t-billing) | Default | At the cap |
246+| --- | --- | --- | --- |
247+| A comped account's monthly budget | `COMPED_MONTHLY_CEILING_MICROS`, or the account's own **Limit** in its terms | $150 a month | New work on the account (agents, checks, workflows, builds) is refused with "<name>'s monthly budget for g1t's own agents is used up … Staff can raise it in sudo". Runs already going finish; the per-run cap still applies to them. It lifts when staff raise the budget or the month turns (UTC). |
248+| The daily breaker | `PLATFORM_DAILY_SPEND_CAP_MICROS` | $75 a day (UTC) | New agent runs on g1t's hosted models that g1t would pay for are refused until 00:00 UTC. Not paused: agents on the workspace's own model provider, checks and builds, and workspaces paying with live payments on the plan (not given by staff) or an enterprise contract. In test mode that exemption covers no one. |
249+
250+`0` turns either off. `CLOUDFLARE_FIXED_MONTHLY_MICROS` ($30: Workers
251+Paid and Workers for Platforms) is shown on the page only.
252+
253+The checks are cheap: `reserve` reads today's total (one indexed sum) and,
254+for a comped account, its month's comped rows. Refusals come back as
255+`paused`, which the compute gate honours for every plan, internal and
256+enterprise included (`packages/contracts/src/compute.ts`). The runner tells
257+billing whether an agent run is on hosted models (`hostedModel` on
258+`reserve`); a caller that does not say is treated as hosted.
259+
260+### Alerts
261+
262+All to `COSTS_ALERT_EMAIL` (`hey@flagon.io`), through the `EMAIL` binding:
263+
264+- **Comped budget**: at 50, 75, 90 and 100%, once each per account and month
265+ (`budget_alerts`), checked every 15 minutes. A jump past several levels
266+ sends only the highest.
267+- **Breaker**: at once, from the charge that trips it; if that email fails,
268+ the 15-minute cron sends it (`spend_breaker.told_at`).
269+
270+While the breaker is open or a comped budget is used up, every sudo page
271+shows a red **Spend cap** bar.
272+
273+### Raising and lifting
274+
275+- **Raise a comped budget**: sudo → the workspace → Billing → **Terms**, set
276+ **Limit $** to the new monthly budget (blank goes back to the default),
277+ with a note. It applies to the next start; nothing to deploy. The change
278+ is in the account's audit log.
279+- **Lift the breaker for today**: sudo → Costs & margin → **g1t's own
280+ spend** → **Lift for today**, with why (`admin_lift_breaker`; audit action
281+ `breaker_lifted`). It resets by itself at 00:00 UTC.
282+- **Change a default**: edit the variable in `services/billing/wrangler.jsonc`
283+ and deploy g1t-billing.
+45−0
550550 costs(days?: number): Promise<CostsReport>;
551551 /** The open margin alerts, for the banner on every page. */
552552 costAlerts(): Promise<MarginAlert[]>;
553+ /** g1t's own spend against its caps: the daily breaker and comped budgets. */
554+ spendCaps(): Promise<SpendCaps>;
555+ /** Lets hosted-model runs start again for the rest of today (UTC); needs a note. */
556+ liftBreaker(note: string, by: string): Promise<Result<SpendCaps>>;
553557 /** Approve or reject a price proposal; a rejection needs a note. An approved rise waits out the notice period. */
554558 decideProposal(id: string, decision: "approve" | "reject", note: string, by: string): Promise<Result<PriceProposal>>;
555559 setCostSettings(settings: CostSettings, by: string): Promise<Result<CostSettings>>;
11131117 lines: CostLineSummary[];
11141118 mappings: CostMapping[];
11151119 settings: CostSettings;
1120+ /** g1t's own spend against its two caps. */
1121+ caps: SpendCaps;
1122+};
1123+
1124+/** What g1t pays for itself, at cost, against its caps (billing's `budget`). */
1125+export type SpendCaps = {
1126+ /** Today (UTC), YYYY-MM-DD, and this month, YYYY-MM. */
1127+ day: string;
1128+ month: string;
1129+ /** What g1t paid for itself today across every workspace. */
1130+ todayMicros: number;
1131+ /** `PLATFORM_DAILY_SPEND_CAP_MICROS`; 0: no breaker. */
1132+ dailyCapMicros: number;
1133+ /** New hosted-model agent runs g1t would pay for are paused. */
1134+ tripped: boolean;
1135+ trippedAt: string | null;
1136+ liftedBy: string | null;
1137+ liftedAt: string | null;
1138+ liftNote: string | null;
1139+ /** This month so far, by what paid: comped, trial, oss, given, unpaid. */
1140+ monthBuckets: { bucket: string; title: string; micros: number }[];
1141+ comped: CompedBudget[];
1142+ /** Free workspaces' share of reconciled costs this month (git, storage, platform). */
1143+ freeTierMicros: number;
1144+ /** `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare subscriptions, an estimate. */
1145+ fixedMonthlyMicros: number;
1146+ /** Money in this month, through the last reconciled day. */
1147+ revenueMicros: number;
1148+};
1149+
1150+/** A comped account's monthly budget, at cost. */
1151+export type CompedBudget = {
1152+ account: string;
1153+ name: string;
1154+ usedMicros: number;
1155+ /** 0: no budget. */
1156+ ceilingMicros: number;
1157+ /** `COMPED_MONTHLY_CEILING_MICROS`, not the account's own limit. */
1158+ defaultCeiling: boolean;
1159+ /** 50, 75, 90, 100, or 0. */
1160+ level: number;
11161161 };
11171162
11181163 export type CostsRun = { lines: number; days: number; proposals: number; alerts: number; problems: string[] };
+2−0
453453 call("admin_record_payment", { workspace, amount_micros: amountMicros, reference, note, by }),
454454 costs: (days) => call("admin_costs", { days: days ?? null }),
455455 costAlerts: () => call("admin_cost_alerts", {}),
456+ spendCaps: () => call("admin_spend_caps", {}),
457+ liftBreaker: (note, by) => call("admin_lift_breaker", { note, by }),
456458 decideProposal: (id, decision, note, by) => call("admin_decide_proposal", { id, decision, note, by }),
457459 setCostSettings: (settings, by) => call("admin_set_cost_settings", { settings, by }),
458460 setCostMapping: (mapping, by) =>
+7−2
220220 case "limit":
221221 return `This workspace reached its spend limit for the month, so nothing new starts. An owner can raise it: ${link}#limit`;
222222 case "paused":
223− return `g1t paused compute for this workspace${detail ? `: ${detail}` : ""}. Contact support@g1t.sh to have it looked at.`;
223+ return `g1t paused compute for this workspace${detail ? `: ${detail.replace(/.$/, "")}` : ""}. Contact support@g1t.sh to have it looked at.`;
224224 case "oss_pool_empty":
225225 return `g1t's open-source pool is used up for this month, so checks and workflows on public repositories wait until next month. Start the $20 plan to run them now: ${link}`;
226226 case "issue_cap":
419419 public: boolean;
420420 kind: ComputeKind;
421421 estimateMicros: number;
422+ /** An agent run on g1t's hosted models, which g1t's daily spend breaker can pause. Unsaid, an agent run is taken to be one. */
423+ hostedModel?: boolean;
422424 };
423425
424426 /** The gate's answer: go ahead (with what was reserved, if anything), or why not. */
519521 public: request.public,
520522 kind: request.kind,
521523 estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)),
524+ ...(request.hostedModel === undefined ? {} : { hostedModel: request.hostedModel }),
522525 });
523526 } catch (error) {
524527 return this.unavailable(plan, refuse, ent, request, String(error));
530533 }
531534 const code = refusalCode(answer.error as { code?: unknown; reason?: unknown });
532535 if (!code) return this.unavailable(plan, refuse, ent, request, answer.error.message);
533− if (alwaysPasses(plan)) {
536+ // A pause holds for every plan: g1t's own caps (a comped account's
537+ // monthly budget, the daily spend breaker) and staff holds included.
538+ if (alwaysPasses(plan) && code !== "paused") {
534539 this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message);
535540 return { ok: true, reservation: null, entitlements: ent };
536541 }
+78−0
1+-- What g1t pays for itself, and the two caps on it (src/budget.rs).
2+--
3+-- g1t_spend: at cost, by day, what paid (comped, trial, oss, given,
4+-- unpaid) and billing account, added to as work settles. The daily
5+-- breaker reads today's total; a comped account's monthly budget reads its
6+-- month's comped rows.
7+CREATE TABLE IF NOT EXISTS g1t_spend (
8+ day TEXT NOT NULL,
9+ bucket TEXT NOT NULL,
10+ account TEXT NOT NULL,
11+ micros INTEGER NOT NULL DEFAULT 0,
12+ PRIMARY KEY (day, bucket, account)
13+);
14+CREATE INDEX IF NOT EXISTS g1t_spend_by_account ON g1t_spend (account, bucket, day);
15+
16+-- The daily breaker: when it tripped and staff were told, and a lift for
17+-- the rest of the day.
18+CREATE TABLE IF NOT EXISTS spend_breaker (
19+ day TEXT PRIMARY KEY,
20+ tripped_at TEXT,
21+ tripped_micros INTEGER,
22+ told_at TEXT,
23+ lifted_by TEXT,
24+ lifted_at TEXT,
25+ lift_note TEXT
26+);
27+
28+-- Comped budgets' 50, 75, 90 and 100% alerts, once each a month.
29+CREATE TABLE IF NOT EXISTS budget_alerts (
30+ account TEXT NOT NULL,
31+ month TEXT NOT NULL,
32+ level INTEGER NOT NULL,
33+ sent_at TEXT NOT NULL,
34+ PRIMARY KEY (account, month, level)
35+);
36+
37+-- This month so far, from the ledger, so the caps start from what was
38+-- already spent. Billing takes no real money yet (Stripe's test key), so
39+-- what no trial, pool or g1t itself paid is 'unpaid'. Usage on a
40+-- workspace's own model provider costs g1t nothing and is left out.
41+INSERT OR IGNORE INTO g1t_spend (day, bucket, account, micros)
42+SELECT substr(l.created_at, 1, 10), 'comped', COALESCE(m.account_id, 'ws_' || l.workspace), SUM(l.cost_micros)
43+FROM ledger l
44+LEFT JOIN account_members m ON m.workspace = l.workspace
45+JOIN billing_accounts b ON b.id = COALESCE(m.account_id, 'ws_' || l.workspace)
46+WHERE l.kind = 'usage' AND COALESCE(l.billed_to, 'g1t') = 'g1t' AND l.cost_micros > 0
47+ AND l.created_at >= strftime('%Y-%m-01', 'now') AND b.terms_kind = 'comped'
48+GROUP BY 1, 3;
49+
50+INSERT OR IGNORE INTO g1t_spend (day, bucket, account, micros)
51+SELECT r.day, k.bucket, r.account,
52+ SUM(CASE
53+ WHEN k.bucket = 'unpaid' THEN r.cost - CASE WHEN r.gross > 0 THEN (r.cost * r.trial / r.gross) + (r.cost * r.oss / r.gross) + (r.cost * r.given / r.gross) ELSE 0 END
54+ WHEN r.gross <= 0 THEN 0
55+ WHEN k.bucket = 'trial' THEN r.cost * r.trial / r.gross
56+ WHEN k.bucket = 'oss' THEN r.cost * r.oss / r.gross
57+ ELSE r.cost * r.given / r.gross
58+ END) AS micros
59+FROM (
60+ SELECT substr(l.created_at, 1, 10) AS day, COALESCE(m.account_id, 'ws_' || l.workspace) AS account,
61+ l.cost_micros AS cost,
62+ -l.amount_micros + COALESCE(l.credit_micros, 0) + COALESCE(l.trial_micros, 0) + COALESCE(l.oss_micros, 0) + COALESCE(l.given_micros, 0) AS gross,
63+ COALESCE(l.trial_micros, 0) AS trial, COALESCE(l.oss_micros, 0) AS oss, COALESCE(l.given_micros, 0) AS given
64+ FROM ledger l
65+ LEFT JOIN account_members m ON m.workspace = l.workspace
66+ LEFT JOIN billing_accounts b ON b.id = COALESCE(m.account_id, 'ws_' || l.workspace)
67+ WHERE l.kind = 'usage' AND COALESCE(l.billed_to, 'g1t') = 'g1t' AND l.cost_micros > 0
68+ AND l.created_at >= strftime('%Y-%m-01', 'now') AND COALESCE(b.terms_kind, 'standard') <> 'comped'
69+) r
70+CROSS JOIN (SELECT 'trial' AS bucket UNION ALL SELECT 'oss' UNION ALL SELECT 'given' UNION ALL SELECT 'unpaid') k
71+GROUP BY r.day, k.bucket, r.account
72+HAVING SUM(CASE
73+ WHEN k.bucket = 'unpaid' THEN r.cost - CASE WHEN r.gross > 0 THEN (r.cost * r.trial / r.gross) + (r.cost * r.oss / r.gross) + (r.cost * r.given / r.gross) ELSE 0 END
74+ WHEN r.gross <= 0 THEN 0
75+ WHEN k.bucket = 'trial' THEN r.cost * r.trial / r.gross
76+ WHEN k.bucket = 'oss' THEN r.cost * r.oss / r.gross
77+ ELSE r.cost * r.given / r.gross
78+ END) > 0;
+1−1
237237 }
238238
239239 /// An account by id, or the account of a workspace by its slug.
240− async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
240+ pub(crate) async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
241241 let id = id.trim().to_lowercase();
242242 if id.starts_with("ent_") {
243243 return self.enterprise(&id).await;
+679−0
1+//! What g1t pays for itself, and two caps on it.
2+//!
3+//! Every charge that settles (an agent run, sandbox time, a build) is
4+//! split by what paid for it, at cost: a customer's real money, or g1t's.
5+//! g1t's part goes to `g1t_spend` by day, bucket and billing account:
6+//!
7+//! - `comped`: work on a comped account (g1t's own, Flagon's), all of it.
8+//! - `trial`, `oss`: the trial credit and the open-source pool.
9+//! - `given`: a free workspace's overrun past its last bit of trial.
10+//! - `unpaid`: charged, but with no real money behind it: Stripe's test
11+//! key, or `FREE_WHILE_BUILDING`.
12+//!
13+//! The plan's included usage and on-demand charges with live payments are
14+//! revenue, not g1t's. A workspace's own model provider costs g1t nothing.
15+//!
16+//! Two caps read it:
17+//!
18+//! 1. **A comped account's monthly budget**: `COMPED_MONTHLY_CEILING_MICROS`
19+//! ($150), or the account's own limit in its terms (sudo, Accounts →
20+//! Terms → Limit). Staff are emailed at 50, 75, 90 and 100%, once each a
21+//! month; at 100% new work on it is refused until staff raise it or the
22+//! month turns. Work already running finishes.
23+//! 2. **The daily breaker**: when g1t's part across every workspace today
24+//! (UTC) reaches `PLATFORM_DAILY_SPEND_CAP_MICROS` ($75), new agent runs
25+//! on g1t's hosted models that g1t would pay for are paused for the rest
26+//! of the day: everyone's except workspaces paying with real money on
27+//! the plan or an enterprise contract. Staff are emailed at once and sudo
28+//! shows a red bar; staff can lift it for the day.
29+//!
30+//! Zero for either variable turns that cap off. See
31+//! docs/BILLING_OPERATIONS.md.
32+
33+use g1t_contracts::billing::{
34+ AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, TermsKind,
35+};
36+use g1t_contracts::time::rfc3339;
37+use g1t_contracts::{FailureCode, Outcome};
38+use g1t_kit::now_ms;
39+use serde::Deserialize;
40+use worker::{Env, Result};
41+
42+use crate::Billing;
43+use crate::credits::Drawn;
44+use crate::limits::alert_level;
45+
46+/// The caps, from the billing service's variables.
47+#[derive(Clone, Debug)]
48+pub(crate) struct Caps {
49+ /// `COMPED_MONTHLY_CEILING_MICROS`: a comped account's monthly budget
50+ /// at cost, unless its terms set one. Zero: none.
51+ pub comped_monthly: i64,
52+ /// `PLATFORM_DAILY_SPEND_CAP_MICROS`: g1t's own spend a day before the
53+ /// breaker trips. Zero: no breaker.
54+ pub daily: i64,
55+ /// `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare's subscriptions, an
56+ /// estimate for sudo.
57+ pub fixed_monthly: i64,
58+ /// `COSTS_ALERT_EMAIL`. Empty: nothing is emailed.
59+ pub alert_to: String,
60+}
61+
62+impl Caps {
63+ pub(crate) fn from_env(env: &Env) -> Self {
64+ let number = |name: &str, default: i64| {
65+ env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).unwrap_or(default).max(0)
66+ };
67+ Caps {
68+ comped_monthly: number("COMPED_MONTHLY_CEILING_MICROS", 150_000_000),
69+ daily: number("PLATFORM_DAILY_SPEND_CAP_MICROS", 75_000_000),
70+ fixed_monthly: number("CLOUDFLARE_FIXED_MONTHLY_MICROS", 30_000_000),
71+ alert_to: env.var("COSTS_ALERT_EMAIL").map(|v| v.to_string().trim().to_owned()).unwrap_or_default(),
72+ }
73+ }
74+}
75+
76+/// g1t's part of one charge, at cost, by what paid for it.
77+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78+pub(crate) struct Share {
79+ pub comped: i64,
80+ pub trial: i64,
81+ pub oss: i64,
82+ pub given: i64,
83+ pub unpaid: i64,
84+}
85+
86+impl Share {
87+ pub fn total(&self) -> i64 {
88+ self.comped + self.trial + self.oss + self.given + self.unpaid
89+ }
90+
91+ pub fn parts(&self) -> [(&'static str, i64); 5] {
92+ [("comped", self.comped), ("trial", self.trial), ("oss", self.oss), ("given", self.given), ("unpaid", self.unpaid)]
93+ }
94+}
95+
96+/// What of a charge costing g1t `cost` g1t paid itself. `charged` is what
97+/// the workspace was charged after `drawn` paid its part (both at price);
98+/// `real_money` is whether payments are live. The plan's included usage
99+/// and what the workspace is charged are revenue only with real money.
100+pub(crate) fn share(cost: i64, charged: i64, drawn: &Drawn, comped: bool, real_money: bool) -> Share {
101+ if cost <= 0 {
102+ return Share::default();
103+ }
104+ if comped {
105+ return Share { comped: cost, ..Share::default() };
106+ }
107+ let gross = charged.max(0) + drawn.total();
108+ if gross <= 0 {
109+ // Charged nothing at all (free while g1t is being built out).
110+ return Share { unpaid: cost, ..Share::default() };
111+ }
112+ let part = |paid: i64| (i128::from(cost) * i128::from(paid.max(0)) / i128::from(gross)) as i64;
113+ let (trial, oss, given) = (part(drawn.trial), part(drawn.oss), part(drawn.given));
114+ let unpaid = if real_money { 0 } else { (cost - trial - oss - given).max(0) };
115+ Share { comped: 0, trial, oss, given, unpaid }
116+}
117+
118+/// A comped account's monthly budget: its own (terms' limit) or the
119+/// default; and whether it is the default. Zero: none.
120+pub(crate) fn comped_ceiling(own: Option<i64>, default: i64) -> (i64, bool) {
121+ match own {
122+ Some(own) => (own.max(0), false),
123+ None => (default.max(0), true),
124+ }
125+}
126+
127+/// Whether a budget is used up.
128+pub(crate) fn used_up(used: i64, ceiling: i64) -> bool {
129+ ceiling > 0 && used >= ceiling
130+}
131+
132+/// Whether the breaker stops new runs: on, reached, and not lifted today.
133+pub(crate) fn breaker_open(today: i64, cap: i64, lifted: bool) -> bool {
134+ cap > 0 && today >= cap && !lifted
135+}
136+
137+/// Whether the breaker is about this start: an agent run on g1t's hosted
138+/// models (an agent run that does not say is taken to be one).
139+pub(crate) fn breaker_applies(kind: ComputeKind, hosted_model: Option<bool>) -> bool {
140+ kind == ComputeKind::Agent && hosted_model.unwrap_or(true)
141+}
142+
143+/// Whether a workspace's spend is covered by revenue, so the breaker
144+/// leaves it alone: live payments, not comped, and on the plan it pays for
145+/// (not given it by staff) or an enterprise contract.
146+pub(crate) fn covered_by_revenue(plan: PlanKind, comped: bool, plan_given: bool, live: bool) -> bool {
147+ live && !comped && match plan {
148+ PlanKind::Enterprise => true,
149+ PlanKind::Paid => !plan_given,
150+ _ => false,
151+ }
152+}
153+
154+/// The alert to send now: the level reached, if higher than any sent this
155+/// month.
156+pub(crate) fn alert_to_send(level: u32, sent: u32) -> Option<u32> {
157+ (level > 0 && level > sent).then_some(level)
158+}
159+
160+/// `$150.00`: whole cents.
161+pub(crate) fn cents(micros: i64) -> String {
162+ let cents = (micros as f64 / 10_000.0).round() as i64;
163+ format!("{}${}.{:02}", if cents < 0 { "-" } else { "" }, cents.abs() / 100, cents.abs() % 100)
164+}
165+
166+/// What a start on a comped account past its budget is told. Staff-only:
167+/// only comped (g1t's own) accounts see it.
168+pub(crate) fn comped_refusal(name: &str, used: i64, ceiling: i64) -> String {
169+ format!(
170+ "{name}'s monthly budget for g1t's own agents is used up ({} of {} this month at cost), so new runs wait. Staff can raise it in sudo: Accounts, {name}, Terms, Limit.",
171+ cents(used),
172+ cents(ceiling)
173+ )
174+}
175+
176+/// What a hosted-model start is told while the breaker is open.
177+pub(crate) fn breaker_refusal(today: i64, cap: i64) -> String {
178+ format!(
179+ "g1t's daily spend breaker is open: g1t has paid {} of its {} a day for work today, so new agent runs on g1t's hosted models wait until 00:00 UTC. Agents on the workspace's own model provider still run, and so does work on the paid plan.",
180+ cents(today),
181+ cents(cap)
182+ )
183+}
184+
185+#[derive(Deserialize)]
186+struct Sum {
187+ micros: Option<i64>,
188+}
189+
190+#[derive(Deserialize)]
191+struct BreakerRow {
192+ tripped_at: Option<String>,
193+ told_at: Option<String>,
194+ lifted_by: Option<String>,
195+ lifted_at: Option<String>,
196+ lift_note: Option<String>,
197+}
198+
199+fn today() -> String {
200+ rfc3339(now_ms())[..10].to_owned()
201+}
202+
203+impl Billing {
204+ fn live(&self) -> bool {
205+ self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live)
206+ }
207+
208+ /// Counts g1t's part of a charge that just settled, and trips the
209+ /// breaker if today reached its cap. Never fails the charge: a problem
210+ /// here is logged.
211+ pub(crate) async fn count_spend(&self, workspace: &str, cost: i64, charged: i64, drawn: &Drawn) {
212+ if let Err(error) = self.try_count_spend(workspace, cost, charged, drawn).await {
213+ worker::console_error!("could not count g1t's spend for {workspace}: {error}");
214+ }
215+ }
216+
217+ async fn try_count_spend(&self, workspace: &str, cost: i64, charged: i64, drawn: &Drawn) -> Result<()> {
218+ if cost <= 0 {
219+ return Ok(());
220+ }
221+ let account = self.account_of(workspace).await?;
222+ let paid = share(cost, charged, drawn, account.terms.kind == TermsKind::Comped, self.live());
223+ if paid.total() == 0 {
224+ return Ok(());
225+ }
226+ let day = today();
227+ let mut writes = vec![];
228+ for (bucket, micros) in paid.parts() {
229+ if micros > 0 {
230+ writes.push(
231+ self.db
232+ .prepare(
233+ "INSERT INTO g1t_spend (day, bucket, account, micros) VALUES (?1, ?2, ?3, ?4)
234+ ON CONFLICT (day, bucket, account) DO UPDATE SET micros = micros + ?4",
235+ )
236+ .bind(&[day.as_str().into(), bucket.into(), account.id.as_str().into(), (micros as f64).into()])?,
237+ );
238+ }
239+ }
240+ self.db.batch(writes).await?;
241+ if self.caps.daily <= 0 {
242+ return Ok(());
243+ }
244+ let total = self.spent_on(&day).await?;
245+ if total < self.caps.daily {
246+ return Ok(());
247+ }
248+ // Tripped: recorded once a day, and staff told at once.
249+ let now = rfc3339(now_ms());
250+ let tripped = self
251+ .db
252+ .prepare(
253+ "INSERT INTO spend_breaker (day, tripped_at, tripped_micros) VALUES (?1, ?2, ?3)
254+ ON CONFLICT (day) DO UPDATE SET tripped_at = ?2, tripped_micros = ?3 WHERE spend_breaker.tripped_at IS NULL
255+ RETURNING day",
256+ )
257+ .bind(&[day.as_str().into(), now.as_str().into(), (total as f64).into()])?
258+ .first::<serde_json::Value>(None)
259+ .await?;
260+ if tripped.is_some() {
261+ self.tell_breaker(&day, total).await?;
262+ }
263+ Ok(())
264+ }
265+
266+ /// g1t's own spend on `day`, across every workspace.
267+ async fn spent_on(&self, day: &str) -> Result<i64> {
268+ Ok(self
269+ .db
270+ .prepare("SELECT SUM(micros) AS micros FROM g1t_spend WHERE day = ?")
271+ .bind(&[day.into()])?
272+ .first::<Sum>(None)
273+ .await?
274+ .and_then(|s| s.micros)
275+ .unwrap_or(0))
276+ }
277+
278+ async fn breaker_row(&self, day: &str) -> Result<Option<BreakerRow>> {
279+ self.db
280+ .prepare("SELECT tripped_at, told_at, lifted_by, lifted_at, lift_note FROM spend_breaker WHERE day = ?")
281+ .bind(&[day.into()])?
282+ .first::<BreakerRow>(None)
283+ .await
284+ }
285+
286+ /// Emails staff that the breaker tripped, and notes it was told.
287+ async fn tell_breaker(&self, day: &str, total: i64) -> Result<()> {
288+ if self.caps.alert_to.is_empty() {
289+ return Ok(());
290+ }
291+ let lifted = self.breaker_row(day).await?.and_then(|row| row.lifted_by);
292+ let mut lines = vec![
293+ format!(
294+ "g1t paid {} for work today ({day}, UTC), its daily cap of {} (PLATFORM_DAILY_SPEND_CAP_MICROS). New agent runs on g1t's hosted models that g1t pays for are paused until 00:00 UTC; workspaces paying with real money, and agents on their own model provider, are not affected. Runs already going finish.",
295+ cents(total),
296+ cents(self.caps.daily)
297+ ),
298+ "To let them start again today: sudo, Costs & margin, Lift for today. To change the cap: PLATFORM_DAILY_SPEND_CAP_MICROS in services/billing/wrangler.jsonc.".to_owned(),
299+ ];
300+ if let Some(by) = lifted {
301+ lines.insert(1, format!("{by} had already lifted it for today, so nothing is paused."));
302+ }
303+ match crate::margin::email_staff(&self.env, &self.caps.alert_to, &format!("g1t: the daily spend breaker tripped at {}", cents(total)), &lines).await {
304+ Ok(()) => {
305+ self.db
306+ .prepare("UPDATE spend_breaker SET told_at = ? WHERE day = ?")
307+ .bind(&[rfc3339(now_ms()).into(), day.into()])?
308+ .run()
309+ .await?;
310+ }
311+ Err(error) => worker::console_error!("could not email the breaker: {error}"),
312+ }
313+ Ok(())
314+ }
315+
316+ /// Why a start is refused by the breaker, if it is.
317+ pub(crate) async fn breaker_refuses(
318+ &self,
319+ plan: PlanKind,
320+ account: &BillingAccount,
321+ kind: ComputeKind,
322+ hosted_model: Option<bool>,
323+ ) -> Result<Option<String>> {
324+ if self.caps.daily <= 0 || !breaker_applies(kind, hosted_model) {
325+ return Ok(None);
326+ }
327+ let comped = account.terms.kind == TermsKind::Comped;
328+ if covered_by_revenue(plan, comped, account.allowances.plan, self.live()) {
329+ return Ok(None);
330+ }
331+ let day = today();
332+ let spent = self.spent_on(&day).await?;
333+ if spent < self.caps.daily {
334+ return Ok(None);
335+ }
336+ let lifted = self.breaker_row(&day).await?.is_some_and(|row| row.lifted_at.is_some());
337+ Ok(breaker_open(spent, self.caps.daily, lifted).then(|| breaker_refusal(spent, self.caps.daily)))
338+ }
339+
340+ /// A comped account's budget this month.
341+ pub(crate) async fn comped_budget(&self, account: &BillingAccount) -> Result<CompedBudget> {
342+ let month = &rfc3339(now_ms())[..7];
343+ let used = self
344+ .db
345+ .prepare("SELECT SUM(micros) AS micros FROM g1t_spend WHERE account = ? AND bucket = 'comped' AND day >= ?")
346+ .bind(&[account.id.as_str().into(), format!("{month}-01").into()])?
347+ .first::<Sum>(None)
348+ .await?
349+ .and_then(|s| s.micros)
350+ .unwrap_or(0);
351+ let (ceiling, default_ceiling) = comped_ceiling(account.terms.ceiling_micros, self.caps.comped_monthly);
352+ Ok(CompedBudget {
353+ account: account.id.clone(),
354+ name: account.name.clone(),
355+ used_micros: used,
356+ ceiling_micros: ceiling,
357+ default_ceiling,
358+ level: alert_level(used, ceiling),
359+ })
360+ }
361+
362+ /// Why new work on a comped account is refused, if its budget is used
363+ /// up. None for every other account.
364+ pub(crate) async fn comped_stop(&self, account: &BillingAccount) -> Result<Option<String>> {
365+ if account.terms.kind != TermsKind::Comped {
366+ return Ok(None);
367+ }
368+ let budget = self.comped_budget(account).await?;
369+ Ok(used_up(budget.used_micros, budget.ceiling_micros).then(|| comped_refusal(&account.name, budget.used_micros, budget.ceiling_micros)))
370+ }
371+
372+ /// Every 15 minutes: comped budgets' alerts, once each level a month,
373+ /// and a tripped breaker staff were not yet told about.
374+ pub(crate) async fn watch_spend(&self) -> Result<()> {
375+ if self.caps.alert_to.is_empty() {
376+ return Ok(());
377+ }
378+ let month = rfc3339(now_ms())[..7].to_owned();
379+ #[derive(Deserialize)]
380+ struct Id {
381+ id: String,
382+ }
383+ let comped = self
384+ .db
385+ .prepare("SELECT id FROM billing_accounts WHERE terms_kind = 'comped'")
386+ .all()
387+ .await?
388+ .results::<Id>()?;
389+ #[derive(Deserialize)]
390+ struct Sent {
391+ level: Option<i64>,
392+ }
393+ for Id { id } in comped {
394+ let Some(account) = self.find_account(&id).await? else { continue };
395+ let budget = self.comped_budget(&account).await?;
396+ let sent = self
397+ .db
398+ .prepare("SELECT MAX(level) AS level FROM budget_alerts WHERE account = ? AND month = ?")
399+ .bind(&[id.as_str().into(), month.as_str().into()])?
400+ .first::<Sent>(None)
401+ .await?
402+ .and_then(|s| s.level)
403+ .unwrap_or(0);
404+ let Some(level) = alert_to_send(budget.level, u32::try_from(sent).unwrap_or(0)) else { continue };
405+ let name = &account.name;
406+ let mut lines = vec![format!(
407+ "{name}'s work has cost g1t {} this month, {level}% of its {} monthly budget ({}).",
408+ cents(budget.used_micros),
409+ cents(budget.ceiling_micros),
410+ if budget.default_ceiling { "COMPED_MONTHLY_CEILING_MICROS" } else { "its own limit, in its terms" }
411+ )];
412+ lines.push(if level >= 100 {
413+ format!("New agent runs, checks and builds on {name} are refused until the budget is raised or the month turns. Runs already going finish. To raise it: sudo, Accounts, {name}, Terms, Limit.")
414+ } else {
415+ format!("At 100%, new work on {name} is refused until staff raise the budget. To raise it now: sudo, Accounts, {name}, Terms, Limit.")
416+ });
417+ let subject = format!("g1t: {name} has used {level}% of its monthly budget");
418+ match crate::margin::email_staff(&self.env, &self.caps.alert_to, &subject, &lines).await {
419+ Ok(()) => {
420+ self.db
421+ .prepare("INSERT OR IGNORE INTO budget_alerts (account, month, level, sent_at) VALUES (?, ?, ?, ?)")
422+ .bind(&[id.as_str().into(), month.as_str().into(), level.into(), rfc3339(now_ms()).into()])?
423+ .run()
424+ .await?;
425+ }
426+ Err(error) => worker::console_error!("could not email {name}'s budget alert: {error}"),
427+ }
428+ }
429+ // A trip whose email did not go out when it happened.
430+ let day = today();
431+ if self.breaker_row(&day).await?.is_some_and(|row| row.tripped_at.is_some() && row.told_at.is_none()) {
432+ let total = self.spent_on(&day).await?;
433+ self.tell_breaker(&day, total).await?;
434+ }
435+ Ok(())
436+ }
437+
438+ /// `admin_spend_caps`: g1t's own spend against its caps.
439+ pub(crate) async fn spend_caps(&self) -> Result<SpendCaps> {
440+ let day = today();
441+ let month = day[..7].to_owned();
442+ let month_start = format!("{month}-01");
443+ let today_micros = self.spent_on(&day).await?;
444+ let row = self.breaker_row(&day).await?;
445+ let lifted = row.as_ref().is_some_and(|r| r.lifted_at.is_some());
446+ #[derive(Deserialize)]
447+ struct Bucket {
448+ bucket: String,
449+ micros: Option<i64>,
450+ }
451+ let rows = self
452+ .db
453+ .prepare("SELECT bucket, SUM(micros) AS micros FROM g1t_spend WHERE day >= ? GROUP BY bucket")
454+ .bind(&[month_start.as_str().into()])?
455+ .all()
456+ .await?
457+ .results::<Bucket>()?;
458+ let month_buckets = ["comped", "trial", "oss", "given", "unpaid"]
459+ .iter()
460+ .map(|bucket| SpendBucket {
461+ bucket: (*bucket).to_owned(),
462+ title: bucket_title(bucket).to_owned(),
463+ micros: rows.iter().find(|r| r.bucket == *bucket).and_then(|r| r.micros).unwrap_or(0),
464+ })
465+ .collect();
466+ #[derive(Deserialize)]
467+ struct Id {
468+ id: String,
469+ }
470+ let ids = self
471+ .db
472+ .prepare("SELECT id FROM billing_accounts WHERE terms_kind = 'comped' ORDER BY id")
473+ .all()
474+ .await?
475+ .results::<Id>()?;
476+ let mut comped = vec![];
477+ for Id { id } in ids {
478+ if let Some(account) = self.find_account(&id).await? {
479+ comped.push(self.comped_budget(&account).await?);
480+ }
481+ }
482+ // Free workspaces' share of the reconciled costs that are not on
483+ // the ledger (models, sandboxes and builds are, above).
484+ let free_tier_micros = self
485+ .db
486+ .prepare(format!(
487+ "SELECT SUM(cost_micros) AS micros FROM workspace_costs
488+ WHERE day >= ?1 AND bucket NOT IN ('models', 'sandboxes', 'deployments')
489+ AND workspace NOT IN ({internal})
490+ AND workspace NOT IN (SELECT workspace FROM workspace_costs WHERE day >= ?1 GROUP BY workspace HAVING SUM(revenue_micros) > 0)",
491+ internal = crate::sales::INTERNAL_SQL
492+ ))
493+ .bind(&[month_start.as_str().into()])?
494+ .first::<Sum>(None)
495+ .await?
496+ .and_then(|s| s.micros)
497+ .unwrap_or(0);
498+ let revenue_micros = self
499+ .db
500+ .prepare("SELECT SUM(cash_micros) AS micros FROM margin_days WHERE day >= ?")
501+ .bind(&[month_start.as_str().into()])?
502+ .first::<Sum>(None)
503+ .await?
504+ .and_then(|s| s.micros)
505+ .unwrap_or(0);
506+ Ok(SpendCaps {
507+ day,
508+ month,
509+ today_micros,
510+ daily_cap_micros: self.caps.daily,
511+ tripped: breaker_open(today_micros, self.caps.daily, lifted),
512+ tripped_at: row.as_ref().and_then(|r| r.tripped_at.clone()),
513+ lifted_by: row.as_ref().and_then(|r| r.lifted_by.clone()),
514+ lifted_at: row.as_ref().and_then(|r| r.lifted_at.clone()),
515+ lift_note: row.as_ref().and_then(|r| r.lift_note.clone()),
516+ month_buckets,
517+ comped,
518+ free_tier_micros,
519+ fixed_monthly_micros: self.caps.fixed_monthly,
520+ revenue_micros,
521+ })
522+ }
523+
524+ /// `admin_lift_breaker`: hosted-model runs start again for the rest of
525+ /// today (UTC).
526+ pub(crate) async fn admin_lift_breaker(&self, a: AdminLiftBreakerArgs) -> Result<Outcome<SpendCaps>> {
527+ let (by, note) = (a.by.trim(), a.note.trim());
528+ if by.is_empty() || note.len() < 5 {
529+ return Ok(Outcome::fail(FailureCode::Invalid, "Say who is lifting it, and why, in the note."));
530+ }
531+ let day = today();
532+ let now = rfc3339(now_ms());
533+ let note: String = note.chars().take(500).collect();
534+ self.db
535+ .prepare(
536+ "INSERT INTO spend_breaker (day, lifted_by, lifted_at, lift_note) VALUES (?1, ?2, ?3, ?4)
537+ ON CONFLICT (day) DO UPDATE SET lifted_by = ?2, lifted_at = ?3, lift_note = ?4",
538+ )
539+ .bind(&[day.as_str().into(), by.into(), now.as_str().into(), note.as_str().into()])?
540+ .run()
541+ .await?;
542+ let spent = self.spent_on(&day).await?;
543+ self.audit("costs", "breaker_lifted", &format!("{day}: lifted at {} of {}: {note}", cents(spent), cents(self.caps.daily)), by)
544+ .await?;
545+ Ok(Outcome::Ok(self.spend_caps().await?))
546+ }
547+}
548+
549+/// How sudo names a bucket of g1t's own spend.
550+pub(crate) fn bucket_title(bucket: &str) -> &'static str {
551+ match bucket {
552+ "comped" => "Comped (g1t's own)",
553+ "trial" => "Trial pool",
554+ "oss" => "Open-source pool",
555+ "given" => "Free overruns g1t covered",
556+ "unpaid" => "Charged without real money",
557+ _ => "Other",
558+ }
559+}
560+
561+#[cfg(test)]
562+mod tests {
563+ use super::*;
564+
565+ fn drawn(credit: i64, trial: i64, oss: i64, given: i64) -> Drawn {
566+ Drawn { credit, trial, oss, given }
567+ }
568+
569+ #[test]
570+ fn comped_work_is_all_g1ts_at_cost() {
571+ let s = share(1_000_000, 0, &Drawn::default(), true, true);
572+ assert_eq!(s, Share { comped: 1_000_000, ..Share::default() });
573+ // Nothing that cost nothing is counted.
574+ assert_eq!(share(0, 0, &Drawn::default(), true, true).total(), 0);
575+ assert_eq!(share(-5, 0, &Drawn::default(), false, false).total(), 0);
576+ }
577+
578+ #[test]
579+ fn pools_pay_their_share_of_the_cost_not_the_price() {
580+ // $1 of cost charged at $1.20, all from the trial: $1 is g1t's.
581+ assert_eq!(share(1_000_000, 0, &drawn(0, 1_200_000, 0, 0), false, true), Share { trial: 1_000_000, ..Share::default() });
582+ // Half the open-source pool, half charged on a live card: half is g1t's.
583+ assert_eq!(share(1_000_000, 600_000, &drawn(0, 0, 600_000, 0), false, true), Share { oss: 500_000, ..Share::default() });
584+ // A free workspace's overrun past its trial.
585+ let s = share(1_000_000, 0, &drawn(0, 300_000, 0, 900_000), false, true);
586+ assert_eq!((s.trial, s.given), (250_000, 750_000));
587+ }
588+
589+ #[test]
590+ fn revenue_is_only_revenue_with_real_money() {
591+ // Plan credit and an on-demand charge, live: none of it is g1t's.
592+ assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, true).total(), 0);
593+ // The same in test mode: all of it.
594+ assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, false), Share { unpaid: 1_000_000, ..Share::default() });
595+ // Free while building: charged nothing, all g1t's.
596+ assert_eq!(share(1_000_000, 0, &Drawn::default(), false, true), Share { unpaid: 1_000_000, ..Share::default() });
597+ }
598+
599+ #[test]
600+ fn a_comped_account_gets_the_default_budget_unless_its_terms_set_one() {
601+ assert_eq!(comped_ceiling(None, 150_000_000), (150_000_000, true));
602+ assert_eq!(comped_ceiling(Some(400_000_000), 150_000_000), (400_000_000, false));
603+ // Zero: no budget.
604+ assert_eq!(comped_ceiling(None, 0), (0, true));
605+ assert!(!used_up(1_000_000_000, 0));
606+ }
607+
608+ #[test]
609+ fn a_comped_budget_refuses_new_work_at_one_hundred_percent() {
610+ let ceiling = 150_000_000;
611+ assert!(!used_up(149_999_999, ceiling));
612+ assert!(used_up(150_000_000, ceiling));
613+ assert!(used_up(151_000_000, ceiling));
614+ let message = comped_refusal("flagon-io", 150_000_000, ceiling);
615+ assert!(message.contains("used up") && message.contains("$150.00 of $150.00") && message.contains("sudo"), "{message}");
616+ }
617+
618+ #[test]
619+ fn budget_alerts_go_once_per_level_each_month() {
620+ let ceiling = 150_000_000;
621+ let mut sent = 0;
622+ let mut emailed = vec![];
623+ // Spend climbs through the month, checked every 15 minutes.
624+ for used in [10_000_000, 74_000_000, 75_000_000, 80_000_000, 112_500_000, 120_000_000, 135_000_000, 140_000_000, 150_000_000, 170_000_000] {
625+ if let Some(level) = alert_to_send(alert_level(used, ceiling), sent) {
626+ emailed.push(level);
627+ sent = level;
628+ }
629+ }
630+ assert_eq!(emailed, vec![50, 75, 90, 100]);
631+ // A jump straight past several levels sends only the highest.
632+ assert_eq!(alert_to_send(alert_level(140_000_000, ceiling), 0), Some(90));
633+ // A new month starts from nothing sent.
634+ assert_eq!(alert_to_send(alert_level(80_000_000, ceiling), 0), Some(50));
635+ }
636+
637+ #[test]
638+ fn the_breaker_trips_at_the_cap_and_staff_can_lift_it_for_the_day() {
639+ let cap = 75_000_000;
640+ assert!(!breaker_open(74_999_999, cap, false));
641+ assert!(breaker_open(75_000_000, cap, false));
642+ // Lifted: open no more today.
643+ assert!(!breaker_open(90_000_000, cap, true));
644+ // Off.
645+ assert!(!breaker_open(1_000_000_000, 0, false));
646+ // Tomorrow's total starts at zero: the breaker resets by itself.
647+ assert!(!breaker_open(0, cap, false));
648+ let message = breaker_refusal(80_000_000, cap);
649+ assert!(message.contains("$80.00 of its $75.00") && message.contains("00:00 UTC"), "{message}");
650+ }
651+
652+ #[test]
653+ fn the_breaker_is_about_hosted_model_agent_runs() {
654+ assert!(breaker_applies(ComputeKind::Agent, Some(true)));
655+ assert!(breaker_applies(ComputeKind::Agent, None));
656+ assert!(!breaker_applies(ComputeKind::Agent, Some(false)));
657+ assert!(!breaker_applies(ComputeKind::Check, None));
658+ assert!(!breaker_applies(ComputeKind::Workflow, Some(true)));
659+ }
660+
661+ #[test]
662+ fn workspaces_paying_with_real_money_are_never_paused_by_the_breaker() {
663+ assert!(covered_by_revenue(PlanKind::Paid, false, false, true));
664+ assert!(covered_by_revenue(PlanKind::Enterprise, false, false, true));
665+ // Test-mode payments are not money.
666+ assert!(!covered_by_revenue(PlanKind::Paid, false, false, false));
667+ // The plan given by staff, comped, free: g1t pays.
668+ assert!(!covered_by_revenue(PlanKind::Paid, false, true, true));
669+ assert!(!covered_by_revenue(PlanKind::Internal, true, false, true));
670+ assert!(!covered_by_revenue(PlanKind::Free, false, false, true));
671+ }
672+
673+ #[test]
674+ fn amounts_read_in_cents() {
675+ assert_eq!(cents(150_000_000), "$150.00");
676+ assert_eq!(cents(1_234_567), "$1.23");
677+ assert_eq!(cents(5_000), "$0.01");
678+ }
679+}
+13−1
387387 if let Some(hold) = account.allowances.hold.as_deref().filter(|h| !h.trim().is_empty()) {
388388 return Ok((Some(format!("g1t staff put a hold on new compute ({}).", hold.trim())), None, Some(FailureCode::Paused)));
389389 }
390+ // A comped account past its monthly budget (`budget`).
391+ if let Some(why) = self.comped_stop(&account).await? {
392+ return Ok((Some(why), None, Some(FailureCode::Paused)));
393+ }
390394 let spike = self.spike_pause(workspace, plan).await?;
391395 if let Some(spike) = &spike {
392396 let why = if spike.status == "stopped" {
492496 return Ok(Outcome::Ok(Reservation { id: new_id("rsv", now), paid_by: PaidBy::OnDemand, held_micros: 0, expires_at }));
493497 }
494498 let plan = self.plan_kind(&workspace).await?;
499+ let account = self.account_of(&workspace).await?;
500+ // g1t's own caps (`budget`), in their own words: a comped account's
501+ // monthly budget, and the daily breaker.
502+ if let Some(why) = self.comped_stop(&account).await? {
503+ return Ok(Outcome::fail(FailureCode::Paused, why));
504+ }
505+ if let Some(why) = self.breaker_refuses(plan, &account, a.kind, a.hosted_model).await? {
506+ return Ok(Outcome::fail(FailureCode::Paused, why));
507+ }
495508 let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_of(&workspace).await?) };
496509 let (paused, _, code) = self.pause_reason(&workspace, plan, limit.as_ref()).await?;
497510 if let (Some(why), Some(code)) = (paused, code) {
498511 return Ok(refusal(code, &workspace, a.kind, &why));
499512 }
500− let account = self.account_of(&workspace).await?;
501513 let month = credits::month_of(&rfc3339(now));
502514 let estimate = credits::with_margin(a.estimate_micros, self.margin_percent);
503515 let verified = matches!(plan, PlanKind::Internal | PlanKind::Enterprise | PlanKind::Paid) || self.card_checked(&workspace).await?;
+1−0
592592 drawn,
593593 })
594594 .await?;
595+ self.count_spend(&workspace, cost_micros, charge - drawn.total(), &drawn).await;
595596 Ok(Outcome::Ok(true))
596597 }
597598 }
+2−0
546546 self.enter(&run.workspace, EntryKind::Usage, -(charge - drawn.total()), &description, &run.id, Some(&row), Some(gateway_micros), None, None)
547547 .await?;
548548 self.record_drawn(&run.id, &drawn).await?;
549+ self.count_spend(&run.workspace, gateway_micros, charge - drawn.total(), &drawn).await;
549550 }
550551 Some(charged) => {
551552 let reported = charged.cost_micros.unwrap_or(0);
582583 )
583584 .await?;
584585 self.record_drawn(&reference, &drawn).await?;
586+ self.count_spend(&run.workspace, delta, change - drawn.total(), &drawn).await;
585587 }
586588 }
587589 Ok(())
+15−0
1818 //! the methods and their arguments.
1919
2020 mod accounts;
21+mod budget;
2122 mod cards;
2223 mod closing;
2324 mod compute;
182183 ceilings: limits::Ceilings,
183184 /// `PREPAID_ONLY`: the old rule, that agents need credit first.
184185 prepaid_only: bool,
186+ /// The caps on what g1t pays for itself; see `budget`.
187+ caps: budget::Caps,
188+ /// The worker's bindings, for emailing staff (`EMAIL`).
189+ env: Env,
185190 }
186191
187192 impl Billing {
718723 )
719724 .await?;
720725 self.record_drawn(&a.run_id, &drawn).await?;
726+ self.count_spend(&run.workspace, charge_micros(a.cost_usd, 0), charge - drawn.total(), &drawn).await;
721727 Ok(Outcome::Ok(true))
722728 }
723729 }
846852 ])?,
847853 ])
848854 .await?;
855+ self.count_spend(&workspace, cost, charge, &drawn).await;
849856 if let Some(reservation) = &a.reservation_id {
850857 self.settle_reservation(SettleArgs { reservation_id: reservation.clone(), actual_micros: cost }).await?;
851858 }
950957 plans: credits::Config::from_env(env),
951958 repos: env.service("REPOS").ok(),
952959 identity: env.service("IDENTITY").ok(),
960+ caps: budget::Caps::from_env(env),
961+ env: env.clone(),
953962 })
954963 }
955964 }
977986 if let Err(error) = billing.invoice_enterprises().await {
978987 worker::console_error!("invoicing enterprises failed: {error}");
979988 }
989+ // Comped budgets' alerts, and a tripped breaker staff were not told of.
990+ if let Err(error) = billing.watch_spend().await {
991+ worker::console_error!("watching g1t's own spend failed: {error}");
992+ }
980993 if let Ok(identity) = env.service("IDENTITY") {
981994 if let Err(error) = billing.warn_limits(&identity).await {
982995 worker::console_error!("warning owners failed: {error}");
11171130 "admin_record_payment" => reply(&billing.admin_record_payment(args(body)?).await?),
11181131 "admin_costs" => reply(&billing.admin_costs(args(body)?, keeper::Keeper::from_env(&env).can_read_bill()).await?),
11191132 "admin_cost_alerts" => reply(&billing.admin_cost_alerts(args(body)?).await?),
1133+ "admin_spend_caps" => reply(&billing.spend_caps().await?),
1134+ "admin_lift_breaker" => reply(&billing.admin_lift_breaker(args(body)?).await?),
11201135 "admin_decide_proposal" => reply(&billing.admin_decide_proposal(args(body)?).await?),
11211136 "admin_set_cost_settings" => reply(&billing.admin_set_cost_settings(args(body)?).await?),
11221137 "admin_set_cost_mapping" => reply(&billing.admin_set_cost_mapping(args(body)?).await?),
+2−1
1414 //! (`SETTLE_DAYS`), never less than the starting ceiling; after three
1515 //! steady months it follows the monthly spend, up to $10,000.
1616 //! - **Reviewed**: a ceiling g1t staff set by hand.
17−//! - **Internal**: g1t's own workspaces, with none.
17+//! - **Internal**: g1t's own workspaces, with none here: what their work
18+//! costs g1t has a monthly budget instead (see `budget`).
1819 //!
1920 //! A ceiling g1t granted (an approved request, or the owners' one-time
2021 //! raise) is a floor under the trust ceiling. Money paid in advance raises
+2−1
491491 }
492492
493493 /// Emails staff through Cloudflare Email Sending, the `EMAIL` binding.
494−async fn email_staff(env: &Env, to: &str, subject: &str, lines: &[String]) -> Result<()> {
494+pub(crate) async fn email_staff(env: &Env, to: &str, subject: &str, lines: &[String]) -> Result<()> {
495495 let link = "https://sudo.g1t.sh/costs";
496496 let text = format!("{}\n\nCosts & margin: {link}\n\nSent by g1t-billing's margin guard (COSTS_ALERT_EMAIL).\n", lines.join("\n\n"));
497497 let mut html = String::from("<div style=\"font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;padding:24px 16px;color:#16150f\">");
14011401 lines,
14021402 mappings,
14031403 settings: self.cost_settings().await?,
1404+ caps: self.spend_caps().await?,
14041405 })
14051406 }
14061407 }
+15−1
110110 "AI_GATEWAY_ID": "g1t",
111111 // Where margin alerts go: a product or all of g1t under the margin
112112 // floor, leaks, drift. Empty sends none (sudo still shows them).
113− "COSTS_ALERT_EMAIL": "hey@flagon.io"
113+ "COSTS_ALERT_EMAIL": "hey@flagon.io",
114+ // What g1t pays for itself, capped (src/budget.rs), at cost:
115+ // a comped account's (flagon-io's) work, $150 a month, unless its
116+ // terms in sudo set its own limit; alerts at 50, 75, 90 and 100% to
117+ // COSTS_ALERT_EMAIL, and at 100% new work on it is refused.
118+ "COMPED_MONTHLY_CEILING_MICROS": "150000000",
119+ // All of g1t's own spend in a day (comped, the trial and open-source
120+ // pools, free overruns, anything charged without real money): at
121+ // $75, new agent runs on hosted models that g1t pays for pause until
122+ // 00:00 UTC; staff are emailed and can lift it in sudo. Workspaces
123+ // paying with real money are never paused. 0 turns either cap off.
124+ "PLATFORM_DAILY_SPEND_CAP_MICROS": "75000000",
125+ // Cloudflare's fixed subscriptions a month, for sudo's figures only:
126+ // Workers Paid ($5) and Workers for Platforms ($25).
127+ "CLOUDFLARE_FIXED_MONTHLY_MICROS": "30000000"
114128 },
115129 // Settling runs every 15 minutes; checking costs daily (keeper::DAILY).
116130 "triggers": { "crons": ["*/15 * * * *", "17 4 * * *"] },
+17−0
145145 assert.equal(alwaysPasses("internal") && alwaysPasses("enterprise") && !alwaysPasses("paid"), true);
146146 });
147147
148+test("a pause from billing holds for every plan: g1t's own budget and its daily breaker", async () => {
149+ for (const plan of ["internal", "enterprise", "paid"] as const) {
150+ const message = "flagon-io's monthly budget for g1t's own agents is used up ($150.00 of $150.00 this month at cost), so new runs wait.";
151+ const { binding, asked } = billing({ entitlements: wire(ent({ plan })), reserve: { ok: false, error: { code: "paused", message } } });
152+ const admitted = await new ComputeGate(binding, memory(), quiet).admit({ ...request("agent"), hostedModel: true });
153+ assert.equal(!admitted.ok && admitted.code, "paused", plan);
154+ assert.equal(!admitted.ok && admitted.message, message);
155+ // Billing hears whether the run is on g1t's hosted models.
156+ assert.equal(asked.find((call) => call.method === "reserve")!.body.hostedModel, true);
157+ }
158+ // Billing's pause reason reads cleanly inside g1t's sentence.
159+ assert.equal(
160+ refusalMessage("paused", "acme", "agent", "Its budget is used up."),
161+ "g1t paused compute for this workspace: Its budget is used up. Contact support@g1t.sh to have it looked at.",
162+ );
163+});
164+
148165 test("billing down: free workspaces fail closed, paying ones go on", async () => {
149166 const down = new Set(["entitlements", "reserve"]);
150167 // Nothing known about the workspace: treated as free.
+8−1
11431143 const microsPerSecond = route ? 0 : sandboxMicros;
11441144 const minutes = estimateMinutes(DEFAULT_MINUTES[task], ent);
11451145 const admission = await compute.admit(
1146− { workspace, repo, public: isPublic, kind: "agent", estimateMicros: agentEstimateMicros(task, minutes, microsPerSecond, ownModel) },
1146+ {
1147+ workspace,
1148+ repo,
1149+ public: isPublic,
1150+ kind: "agent",
1151+ estimateMicros: agentEstimateMicros(task, minutes, microsPerSecond, ownModel),
1152+ hostedModel: !ownModel,
1153+ },
11471154 ent,
11481155 );
11491156 if (!admission.ok) return { ok: false, waiting: false, code: admission.code, message: admission.message };