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.
22 files+1294−160/22 viewed
| 69 | 69 | return [ | |
| 70 | 70 | ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"], | |
| 71 | 71 | ["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 | + | ], | |
| 73 | 82 | ["Until", terms.until ? terms.until.slice(0, 10) : "No end"], | |
| 74 | 83 | ["Note", terms.note || "—"], | |
| 75 | 84 | ]; | |
| 242 | 251 | <Field label="Discount %" hint="Custom only."> | |
| 243 | 252 | <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} /> | |
| 244 | 253 | </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)."> | |
| 246 | 255 | <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} /> | |
| 247 | 256 | </Field> | |
| 248 | 257 | <Field label="Until" hint="Blank: no end. UTC."> |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { CostDay } from "@g1t/contracts"; | |
| 4 | + | import type { CostDay, SpendCaps } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import { | |
| 7 | 7 | daySeries, | |
| 13 | 13 | parseMapping, | |
| 14 | 14 | parseRange, | |
| 15 | 15 | percentLabel, | |
| 16 | + | spendBanner, | |
| 17 | + | spendRows, | |
| 16 | 18 | unitDollars, | |
| 17 | 19 | } from "./costs.ts"; | |
| 18 | 20 | ||
| 112 | 114 | assert.equal(parseMapping(form({ product: "r2", meter: "*", bucket: "" })).ok, false); | |
| 113 | 115 | assert.equal(parseMapping(form({ product: "r2", meter: "*", bucket: "git", driftPercent: "-1" })).ok, false); | |
| 114 | 116 | }); | |
| 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 | + | }); |
| 2 | 2 | * Costs & margin: the arithmetic behind the page, apart from the SVG and | |
| 3 | 3 | * the Workers runtime so it can be tested under Node. Money is in micros. | |
| 4 | 4 | */ | |
| 5 | − | import type { CostDay, CostMappingInput, CostSettings } from "@g1t/contracts"; | |
| 5 | + | import type { CostDay, CostMappingInput, CostSettings, SpendCaps } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | − | import { parseDollars } from "./money.ts"; | |
| 7 | + | import { parseDollars, usd } from "./money.ts"; | |
| 8 | 8 | ||
| 9 | 9 | /** Buckets Cloudflare does not bill: their cost is g1t's own figure. */ | |
| 10 | 10 | export const NOT_CLOUDFLARE = new Set(["models"]); | |
| 164 | 164 | }, | |
| 165 | 165 | }; | |
| 166 | 166 | } | |
| 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 | + | } |
| 7 | 7 | import { MobileBar, Sidebar } from "./components/shell"; | |
| 8 | 8 | import { ButtonLink } from "./components/ui"; | |
| 9 | 9 | import type { NavCounts } from "./lib/nav"; | |
| 10 | + | import { spendBanner } from "./lib/costs"; | |
| 10 | 11 | import { admin, identity, statusAdmin } from "./lib/services.server"; | |
| 11 | 12 | import { settle } from "./lib/settle"; | |
| 12 | 13 | import { requireStaff, zoneContext } from "./lib/staff"; | |
| 26 | 27 | export async function loader({ context }: Route.LoaderArgs) { | |
| 27 | 28 | const { email } = requireStaff(context); | |
| 28 | 29 | // 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([ | |
| 30 | 31 | settle(identity.waitlistPending()), | |
| 31 | 32 | settle(statusAdmin.openCount()), | |
| 32 | 33 | settle(admin.costAlerts()), | |
| 34 | + | settle(admin.spendCaps()), | |
| 33 | 35 | ]); | |
| 34 | 36 | const counts: NavCounts = { waitlist: waitlist.ok ? waitlist.value : 0, incidents: incidents.ok ? incidents.value : 0 }; | |
| 35 | 37 | // Every page says times in this zone (components/ui.tsx `When`). | |
| 39 | 41 | const margin = alerts.ok | |
| 40 | 42 | ? alerts.value.filter((alert) => rank.includes(alert.kind)).sort((a, b) => rank.indexOf(a.kind) - rank.indexOf(b.kind)) | |
| 41 | 43 | : []; | |
| 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 }; | |
| 43 | 48 | } | |
| 44 | 49 | ||
| 45 | 50 | export function Layout({ children }: { children: React.ReactNode }) { | |
| 66 | 71 | </a> | |
| 67 | 72 | </div> | |
| 68 | 73 | )} | |
| 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 | + | )} | |
| 69 | 82 | {children} | |
| 70 | 83 | </div> | |
| 71 | 84 | {/* No <Scripts />: sudo ships no JavaScript, and its policy allows none. */} |
| 1 | + | import type { ReactNode } from "react"; | |
| 1 | 2 | import { Link, data, redirect } from "react-router"; | |
| 2 | 3 | ||
| 3 | 4 | import type { CostsReport, PriceProposal } from "@g1t/contracts"; | |
| 6 | 7 | import { DaysChart } from "~/components/costs"; | |
| 7 | 8 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Stat, When } from "~/components/ui"; | |
| 8 | 9 | import { | |
| 10 | + | capPercent, | |
| 9 | 11 | countLabel, | |
| 10 | 12 | daySeries, | |
| 11 | 13 | driftLabel, | |
| 15 | 17 | parseMapping, | |
| 16 | 18 | parseRange, | |
| 17 | 19 | percentLabel, | |
| 20 | + | spendRows, | |
| 18 | 21 | unitDollars, | |
| 19 | 22 | } from "~/lib/costs"; | |
| 20 | 23 | import { dollarsField, usd } from "~/lib/money"; | |
| 31 | 34 | settings: "Guardrails saved. They apply from the next run.", | |
| 32 | 35 | mapping: "Mapping saved. It applies from the next run; read the bill now to see it.", | |
| 33 | 36 | 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.", | |
| 34 | 38 | }; | |
| 35 | 39 | ||
| 36 | 40 | const RANGES = [7, 30, 90]; | |
| 70 | 74 | if (run.value.value.problems.length > 0) return fail("run", run.value.value.problems.join(" ")); | |
| 71 | 75 | throw back("run"); | |
| 72 | 76 | } | |
| 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 | + | } | |
| 73 | 85 | if (intent === "decide") { | |
| 74 | 86 | const id = String(form.get("id") ?? ""); | |
| 75 | 87 | const decision = form.get("decision") === "approve" ? "approve" : "reject"; | |
| 202 | 214 | /> | |
| 203 | 215 | </div> | |
| 204 | 216 | ||
| 217 | + | <SpendSection caps={report.caps} error={failed?.section === "lift" ? failed.error : null} /> | |
| 218 | + | ||
| 205 | 219 | <Section | |
| 206 | 220 | className="mt-6" | |
| 207 | 221 | title={product ? `${product.title}, by day` : "By day"} | |
| 603 | 617 | ); | |
| 604 | 618 | } | |
| 605 | 619 | ||
| 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 | + | ||
| 606 | 747 | function MappingsTable({ report }: { report: CostsReport }) { | |
| 607 | 748 | return ( | |
| 608 | 749 | <div className="-mx-4 overflow-x-auto sm:-mx-5"> |
| 1061 | 1061 | /// whole time cap. | |
| 1062 | 1062 | #[serde(alias = "estimate_micros")] | |
| 1063 | 1063 | 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>, | |
| 1064 | 1069 | } | |
| 1065 | 1070 | ||
| 1066 | 1071 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 2433 | 2438 | pub lines: Vec<CostLineSummary>, | |
| 2434 | 2439 | pub mappings: Vec<CostMapping>, | |
| 2435 | 2440 | 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, | |
| 2436 | 2517 | } | |
| 2437 | 2518 | ||
| 2438 | 2519 | /// `admin_cost_alerts`: the open margin alerts, for sudo's banner. |
| 4 | 4 | prices at cost plus 20%, and tells staff when the margin slips. Internal. | |
| 5 | 5 | Code: `services/billing/src/costs.rs` (reading the bill), `margin.rs` | |
| 6 | 6 | (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)). | |
| 8 | 10 | Page: sudo **Costs & margin** (`/costs`). | |
| 9 | 11 | ||
| 10 | 12 | Several Cloudflare products g1t runs on are new. Artifacts bills | |
| 208 | 210 | Every create is `IF NOT EXISTS` and every seed `INSERT OR IGNORE`; the one | |
| 209 | 211 | `ALTER` is applied once by D1's migration tracking. Migration | |
| 210 | 212 | `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. |
| 550 | 550 | costs(days?: number): Promise<CostsReport>; | |
| 551 | 551 | /** The open margin alerts, for the banner on every page. */ | |
| 552 | 552 | 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>>; | |
| 553 | 557 | /** Approve or reject a price proposal; a rejection needs a note. An approved rise waits out the notice period. */ | |
| 554 | 558 | decideProposal(id: string, decision: "approve" | "reject", note: string, by: string): Promise<Result<PriceProposal>>; | |
| 555 | 559 | setCostSettings(settings: CostSettings, by: string): Promise<Result<CostSettings>>; | |
| 1113 | 1117 | lines: CostLineSummary[]; | |
| 1114 | 1118 | mappings: CostMapping[]; | |
| 1115 | 1119 | 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; | |
| 1116 | 1161 | }; | |
| 1117 | 1162 | ||
| 1118 | 1163 | export type CostsRun = { lines: number; days: number; proposals: number; alerts: number; problems: string[] }; |
| 453 | 453 | call("admin_record_payment", { workspace, amount_micros: amountMicros, reference, note, by }), | |
| 454 | 454 | costs: (days) => call("admin_costs", { days: days ?? null }), | |
| 455 | 455 | costAlerts: () => call("admin_cost_alerts", {}), | |
| 456 | + | spendCaps: () => call("admin_spend_caps", {}), | |
| 457 | + | liftBreaker: (note, by) => call("admin_lift_breaker", { note, by }), | |
| 456 | 458 | decideProposal: (id, decision, note, by) => call("admin_decide_proposal", { id, decision, note, by }), | |
| 457 | 459 | setCostSettings: (settings, by) => call("admin_set_cost_settings", { settings, by }), | |
| 458 | 460 | setCostMapping: (mapping, by) => |
| 220 | 220 | case "limit": | |
| 221 | 221 | return `This workspace reached its spend limit for the month, so nothing new starts. An owner can raise it: ${link}#limit`; | |
| 222 | 222 | 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.`; | |
| 224 | 224 | case "oss_pool_empty": | |
| 225 | 225 | 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}`; | |
| 226 | 226 | case "issue_cap": | |
| 419 | 419 | public: boolean; | |
| 420 | 420 | kind: ComputeKind; | |
| 421 | 421 | 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; | |
| 422 | 424 | }; | |
| 423 | 425 | ||
| 424 | 426 | /** The gate's answer: go ahead (with what was reserved, if anything), or why not. */ | |
| 519 | 521 | public: request.public, | |
| 520 | 522 | kind: request.kind, | |
| 521 | 523 | estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)), | |
| 524 | + | ...(request.hostedModel === undefined ? {} : { hostedModel: request.hostedModel }), | |
| 522 | 525 | }); | |
| 523 | 526 | } catch (error) { | |
| 524 | 527 | return this.unavailable(plan, refuse, ent, request, String(error)); | |
| 530 | 533 | } | |
| 531 | 534 | const code = refusalCode(answer.error as { code?: unknown; reason?: unknown }); | |
| 532 | 535 | 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") { | |
| 534 | 539 | this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message); | |
| 535 | 540 | return { ok: true, reservation: null, entitlements: ent }; | |
| 536 | 541 | } |
| 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; |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | /// 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>> { | |
| 241 | 241 | let id = id.trim().to_lowercase(); | |
| 242 | 242 | if id.starts_with("ent_") { | |
| 243 | 243 | return self.enterprise(&id).await; |
| 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 | + | } |
| 387 | 387 | if let Some(hold) = account.allowances.hold.as_deref().filter(|h| !h.trim().is_empty()) { | |
| 388 | 388 | return Ok((Some(format!("g1t staff put a hold on new compute ({}).", hold.trim())), None, Some(FailureCode::Paused))); | |
| 389 | 389 | } | |
| 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 | + | } | |
| 390 | 394 | let spike = self.spike_pause(workspace, plan).await?; | |
| 391 | 395 | if let Some(spike) = &spike { | |
| 392 | 396 | let why = if spike.status == "stopped" { | |
| 492 | 496 | return Ok(Outcome::Ok(Reservation { id: new_id("rsv", now), paid_by: PaidBy::OnDemand, held_micros: 0, expires_at })); | |
| 493 | 497 | } | |
| 494 | 498 | 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 | + | } | |
| 495 | 508 | let limit = if plan == PlanKind::Internal { None } else { Some(self.limit_of(&workspace).await?) }; | |
| 496 | 509 | let (paused, _, code) = self.pause_reason(&workspace, plan, limit.as_ref()).await?; | |
| 497 | 510 | if let (Some(why), Some(code)) = (paused, code) { | |
| 498 | 511 | return Ok(refusal(code, &workspace, a.kind, &why)); | |
| 499 | 512 | } | |
| 500 | − | let account = self.account_of(&workspace).await?; | |
| 501 | 513 | let month = credits::month_of(&rfc3339(now)); | |
| 502 | 514 | let estimate = credits::with_margin(a.estimate_micros, self.margin_percent); | |
| 503 | 515 | let verified = matches!(plan, PlanKind::Internal | PlanKind::Enterprise | PlanKind::Paid) || self.card_checked(&workspace).await?; |
| 592 | 592 | drawn, | |
| 593 | 593 | }) | |
| 594 | 594 | .await?; | |
| 595 | + | self.count_spend(&workspace, cost_micros, charge - drawn.total(), &drawn).await; | |
| 595 | 596 | Ok(Outcome::Ok(true)) | |
| 596 | 597 | } | |
| 597 | 598 | } |
| 546 | 546 | self.enter(&run.workspace, EntryKind::Usage, -(charge - drawn.total()), &description, &run.id, Some(&row), Some(gateway_micros), None, None) | |
| 547 | 547 | .await?; | |
| 548 | 548 | self.record_drawn(&run.id, &drawn).await?; | |
| 549 | + | self.count_spend(&run.workspace, gateway_micros, charge - drawn.total(), &drawn).await; | |
| 549 | 550 | } | |
| 550 | 551 | Some(charged) => { | |
| 551 | 552 | let reported = charged.cost_micros.unwrap_or(0); | |
| 582 | 583 | ) | |
| 583 | 584 | .await?; | |
| 584 | 585 | self.record_drawn(&reference, &drawn).await?; | |
| 586 | + | self.count_spend(&run.workspace, delta, change - drawn.total(), &drawn).await; | |
| 585 | 587 | } | |
| 586 | 588 | } | |
| 587 | 589 | Ok(()) |
| 18 | 18 | //! the methods and their arguments. | |
| 19 | 19 | ||
| 20 | 20 | mod accounts; | |
| 21 | + | mod budget; | |
| 21 | 22 | mod cards; | |
| 22 | 23 | mod closing; | |
| 23 | 24 | mod compute; | |
| 182 | 183 | ceilings: limits::Ceilings, | |
| 183 | 184 | /// `PREPAID_ONLY`: the old rule, that agents need credit first. | |
| 184 | 185 | 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, | |
| 185 | 190 | } | |
| 186 | 191 | ||
| 187 | 192 | impl Billing { | |
| 718 | 723 | ) | |
| 719 | 724 | .await?; | |
| 720 | 725 | 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; | |
| 721 | 727 | Ok(Outcome::Ok(true)) | |
| 722 | 728 | } | |
| 723 | 729 | } | |
| 846 | 852 | ])?, | |
| 847 | 853 | ]) | |
| 848 | 854 | .await?; | |
| 855 | + | self.count_spend(&workspace, cost, charge, &drawn).await; | |
| 849 | 856 | if let Some(reservation) = &a.reservation_id { | |
| 850 | 857 | self.settle_reservation(SettleArgs { reservation_id: reservation.clone(), actual_micros: cost }).await?; | |
| 851 | 858 | } | |
| 950 | 957 | plans: credits::Config::from_env(env), | |
| 951 | 958 | repos: env.service("REPOS").ok(), | |
| 952 | 959 | identity: env.service("IDENTITY").ok(), | |
| 960 | + | caps: budget::Caps::from_env(env), | |
| 961 | + | env: env.clone(), | |
| 953 | 962 | }) | |
| 954 | 963 | } | |
| 955 | 964 | } | |
| 977 | 986 | if let Err(error) = billing.invoice_enterprises().await { | |
| 978 | 987 | worker::console_error!("invoicing enterprises failed: {error}"); | |
| 979 | 988 | } | |
| 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 | + | } | |
| 980 | 993 | if let Ok(identity) = env.service("IDENTITY") { | |
| 981 | 994 | if let Err(error) = billing.warn_limits(&identity).await { | |
| 982 | 995 | worker::console_error!("warning owners failed: {error}"); | |
| 1117 | 1130 | "admin_record_payment" => reply(&billing.admin_record_payment(args(body)?).await?), | |
| 1118 | 1131 | "admin_costs" => reply(&billing.admin_costs(args(body)?, keeper::Keeper::from_env(&env).can_read_bill()).await?), | |
| 1119 | 1132 | "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?), | |
| 1120 | 1135 | "admin_decide_proposal" => reply(&billing.admin_decide_proposal(args(body)?).await?), | |
| 1121 | 1136 | "admin_set_cost_settings" => reply(&billing.admin_set_cost_settings(args(body)?).await?), | |
| 1122 | 1137 | "admin_set_cost_mapping" => reply(&billing.admin_set_cost_mapping(args(body)?).await?), |
| 14 | 14 | //! (`SETTLE_DAYS`), never less than the starting ceiling; after three | |
| 15 | 15 | //! steady months it follows the monthly spend, up to $10,000. | |
| 16 | 16 | //! - **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`). | |
| 18 | 19 | //! | |
| 19 | 20 | //! A ceiling g1t granted (an approved request, or the owners' one-time | |
| 20 | 21 | //! raise) is a floor under the trust ceiling. Money paid in advance raises |
| 491 | 491 | } | |
| 492 | 492 | ||
| 493 | 493 | /// 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<()> { | |
| 495 | 495 | let link = "https://sudo.g1t.sh/costs"; | |
| 496 | 496 | let text = format!("{}\n\nCosts & margin: {link}\n\nSent by g1t-billing's margin guard (COSTS_ALERT_EMAIL).\n", lines.join("\n\n")); | |
| 497 | 497 | let mut html = String::from("<div style=\"font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;padding:24px 16px;color:#16150f\">"); | |
| 1401 | 1401 | lines, | |
| 1402 | 1402 | mappings, | |
| 1403 | 1403 | settings: self.cost_settings().await?, | |
| 1404 | + | caps: self.spend_caps().await?, | |
| 1404 | 1405 | }) | |
| 1405 | 1406 | } | |
| 1406 | 1407 | } |
| 110 | 110 | "AI_GATEWAY_ID": "g1t", | |
| 111 | 111 | // Where margin alerts go: a product or all of g1t under the margin | |
| 112 | 112 | // 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" | |
| 114 | 128 | }, | |
| 115 | 129 | // Settling runs every 15 minutes; checking costs daily (keeper::DAILY). | |
| 116 | 130 | "triggers": { "crons": ["*/15 * * * *", "17 4 * * *"] }, |
| 145 | 145 | assert.equal(alwaysPasses("internal") && alwaysPasses("enterprise") && !alwaysPasses("paid"), true); | |
| 146 | 146 | }); | |
| 147 | 147 | ||
| 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 | + | ||
| 148 | 165 | test("billing down: free workspaces fail closed, paying ones go on", async () => { | |
| 149 | 166 | const down = new Set(["entitlements", "reserve"]); | |
| 150 | 167 | // Nothing known about the workspace: treated as free. |
| 1143 | 1143 | const microsPerSecond = route ? 0 : sandboxMicros; | |
| 1144 | 1144 | const minutes = estimateMinutes(DEFAULT_MINUTES[task], ent); | |
| 1145 | 1145 | 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 | + | }, | |
| 1147 | 1154 | ent, | |
| 1148 | 1155 | ); | |
| 1149 | 1156 | if (!admission.ok) return { ok: false, waiting: false, code: admission.code, message: admission.message }; |