Skip to content

Commit

Merge costs and margin review: gateway query, own spend, discount meters, superseded rises

syntaqxcommitted Parents111c3bac02661bBrowse files
14 files+558−1070/14 viewed
+50−0
66 import {
77 daySeries,
88 daysBetween,
9+ marginOnPrice,
910 marginPercent,
1011 marginTone,
1112 parseBucket,
1314 parseMapping,
1415 parseRange,
1516 percentLabel,
17+ proposalOutcome,
1618 spendBanner,
1719 spendRows,
20+ subscriptionsOver,
1821 unitDollars,
22+ versionCells,
23+ whoPaid,
1924 } from "./costs.ts";
2025
2126 const day = (d: string, bucket: string, cf: number, own: number, value: number, cash: number): CostDay => ({
150155 assert.deepEqual(rows.map((r) => r.key), ["comped", "trial", "free", "fixed"]);
151156 assert.equal(totalMicros, 76_000_000);
152157 });
158+
159+test("charged without real money is what it cost g1t, never its price", () => {
160+ const { rows } = spendRows(caps({ monthBuckets: [{ bucket: "unpaid", title: "Charged without real money", micros: 7_610_000 }] }));
161+ assert.match(rows[0]!.note, /what it cost g1t, not what was charged/);
162+});
163+
164+test("margins are a share of the price: cost plus 20% is 16.7%", () => {
165+ assert.equal(percentLabel(marginOnPrice(20)), "16.7%");
166+ assert.equal(marginOnPrice(0), 0);
167+ // As By product showed it: $2.99 charged for models that cost $2.49.
168+ assert.equal(percentLabel(marginPercent(2_988_000, 2_490_000)), "16.7%");
169+});
170+
171+test("who g1t paid is the statement's all-in cost, subscriptions included", () => {
172+ const subscriptions = subscriptionsOver(30_000_000, 30);
173+ const paid = whoPaid({ costMicros: 2_490_000, cloudflareCostMicros: 0, modelsCostMicros: 2_490_000 }, subscriptions);
174+ assert.deepEqual(paid, { totalMicros: 32_490_000, cloudflareMicros: 0, subscriptionsMicros: 30_000_000, modelsMicros: 2_490_000 });
175+ assert.equal(subscriptionsOver(30_000_000, 7), 7_000_000);
176+ // From older billing, Cloudflare's part is the rest.
177+ assert.equal(whoPaid({ costMicros: 3_000_000, modelsCostMicros: 1_000_000 }, 0).cloudflareMicros, 2_000_000);
178+});
179+
180+test("a rate g1t sets shows no cost, and a weight is no money", () => {
181+ assert.deepEqual(versionCells({ costMicros: 250_000, priceMicros: 250_000, basis: "rate" }), {
182+ cost: "—",
183+ price: "$0.250",
184+ note: "g1t's own rate: no cost behind it",
185+ });
186+ assert.equal(versionCells({ costMicros: 100_000, priceMicros: 100_000, basis: "weight" }).price, "×0.1");
187+ assert.deepEqual(versionCells({ costMicros: 16.44, priceMicros: 19.73, basis: "cost" }), { cost: unitDollars(16.44), price: unitDollars(19.73), note: null });
188+ // Older billing sends no basis: a cost.
189+ assert.equal(versionCells({ costMicros: 1_000_000, priceMicros: 1_200_000 }).cost, "$1.00");
190+});
191+
192+test("a rise replaced before its date says so, rather than missing its date", () => {
193+ assert.equal(
194+ proposalOutcome({ status: "superseded", decidedBy: "guardrail", effectiveAt: null }),
195+ "applied by guardrail, then replaced by a later measurement before it took effect; nothing was charged at it",
196+ );
197+ // From before billing marked them superseded.
198+ assert.match(proposalOutcome({ status: "applied", decidedBy: "guardrail", effectiveAt: null }) ?? "", /replaced by a later measurement/);
199+ assert.equal(proposalOutcome({ status: "applied", decidedBy: "guardrail", effectiveAt: "2026-10-22T04:18:12.571Z" }), "applied by guardrail");
200+ assert.equal(proposalOutcome({ status: "superseded", decidedBy: null, effectiveAt: null }), "replaced by a later measurement");
201+ assert.equal(proposalOutcome({ status: "open", decidedBy: null, effectiveAt: null }), null);
202+});
+59−7
4444 return [...totals.values()];
4545 }
4646
47+/**
48+ * The margin on the price a markup gives, in percent: cost plus 20% is a
49+ * 16.7% margin, since the 20% is of the cost and the margin of the price.
50+ */
51+export function marginOnPrice(markupPercent: number): number {
52+ return markupPercent > -100 ? (markupPercent / (100 + markupPercent)) * 100 : 0;
53+}
54+
55+/**
56+ * Who g1t paid over the range: Cloudflare's usage bill, its subscriptions
57+ * (a month's, over the range) and the model providers. The same total as
58+ * the statement's All in.
59+ */
60+export function whoPaid(
61+ overall: { costMicros: number; cloudflareCostMicros?: number; modelsCostMicros?: number },
62+ subscriptionsMicros: number,
63+): { totalMicros: number; cloudflareMicros: number; subscriptionsMicros: number; modelsMicros: number } {
64+ const modelsMicros = overall.modelsCostMicros ?? 0;
65+ const cloudflareMicros = overall.cloudflareCostMicros ?? overall.costMicros - modelsMicros;
66+ return { totalMicros: overall.costMicros + subscriptionsMicros, cloudflareMicros, subscriptionsMicros, modelsMicros };
67+}
68+
69+/** Cloudflare's subscriptions over a range of days: a month's, pro rata. */
70+export function subscriptionsOver(monthlyMicros: number, days: number): number {
71+ return Math.round((monthlyMicros * days) / 30);
72+}
73+
74+/**
75+ * A price version's cost and price as the table shows them. A rate g1t
76+ * sets has no cost behind it; a weight is a multiplier, not money.
77+ */
78+export function versionCells(v: { costMicros: number; priceMicros: number; basis?: string }): { cost: string; price: string; note: string | null } {
79+ if (v.basis === "weight") {
80+ const weight = Number((v.costMicros / 1_000_000).toFixed(6));
81+ return { cost: "—", price: `×${weight}`, note: "A weight on the agent rate's tokens, not money" };
82+ }
83+ if (v.basis === "rate") return { cost: "—", price: unitDollars(v.priceMicros), note: "g1t's own rate: no cost behind it" };
84+ return { cost: unitDollars(v.costMicros), price: unitDollars(v.priceMicros), note: null };
85+}
86+
87+/**
88+ * What became of a proposal, in words, for its line: who decided it, and
89+ * when one never took effect because a later measurement replaced it.
90+ */
91+export function proposalOutcome(p: { status: string; decidedBy: string | null; effectiveAt: string | null }): string | null {
92+ const by = p.decidedBy ? `${p.status === "superseded" ? "applied" : p.status} by ${p.decidedBy}` : null;
93+ const replaced = "replaced by a later measurement before it took effect; nothing was charged at it";
94+ if (p.status === "superseded") return by ? `${by}, then ${replaced}` : "replaced by a later measurement";
95+ if ((p.status === "applied" || p.status === "approved") && !p.effectiveAt) return by ? `${by}, then ${replaced}` : replaced;
96+ return by;
97+}
98+
4799 /** Margin as a whole percent of revenue; none when there was none. */
48100 export function marginPercent(revenueMicros: number, costMicros: number): number | null {
49101 return revenueMicros > 0 ? ((revenueMicros - costMicros) / revenueMicros) * 100 : null;
192244 /** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */
193245 export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } {
194246 const notes: Record<string, string> = {
195− comped: "Work on accounts with a 100% discount, at cost",
196− trial: "Trial credit, at cost",
197− oss: "Checks and workflows on public repositories, at cost",
198− given: "Free workspaces' overruns past their trial",
199− unpaid: "Charged, but no real money yet (test-mode payments)",
247+ comped: "Work on accounts with a 100% discount: what it cost g1t, not its price",
248+ trial: "Trial credit, at what it cost g1t",
249+ oss: "Checks and workflows on public repositories, at what they cost g1t",
250+ given: "Free workspaces' overruns past their trial, at what they cost g1t",
251+ unpaid: "Usage charged while payments are in Stripe's test mode: what it cost g1t, not what was charged",
200252 };
201253 const rows = caps.monthBuckets.map((b) => ({ key: b.bucket, title: b.title, micros: b.micros, note: notes[b.bucket] ?? "" }));
202− rows.push({ key: "free", title: "Free tier", micros: caps.freeTierMicros, note: "Free workspaces' share of git, storage and platform, reconciled through yesterday" });
254+ rows.push({ key: "free", title: "Free tier", micros: caps.freeTierMicros, note: "Free workspaces' share of git, storage and platform, as last reconciled" });
203255 rows.push({
204256 key: "fixed",
205257 title: "Cloudflare subscriptions",
206258 micros: caps.fixedMonthlyMicros,
207− note: caps.fixedSource === "cloudflare" ? "A month, as Cloudflare lists them" : "A month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)",
259+ note: caps.fixedSource === "cloudflare" ? "The whole month, as Cloudflare lists them" : "The whole month, estimated (CLOUDFLARE_FIXED_MONTHLY_MICROS)",
208260 });
209261 return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) };
210262 }
+29−24
55 import type { Route } from "./+types/costs-bill";
66 import { CostsHeader } from "~/components/costs-header";
77 import { Badge, Button, EmptyState, Field, Input, Notice, Section, When } from "~/components/ui";
8−import { countLabel, driftLabel, percentLabel, unitDollars } from "~/lib/costs";
8+import { countLabel, driftLabel, percentLabel, proposalOutcome, unitDollars, versionCells } from "~/lib/costs";
99 import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
1010 import { dollarsField, usd } from "~/lib/money";
1111
111111 </tr>
112112 </thead>
113113 <tbody>
114− {report.versions.map((v) => (
115− <tr key={v.id} className="border-b border-line align-top last:border-0">
116− <td className="px-4 py-2.5 sm:px-5">
117− <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
118− {!v.appliedAt && (
119− <span className="ml-2">
120− <Badge tone="info">Coming</Badge>
121− </span>
122− )}
123− </td>
124− <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
125− <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
126− <td className="px-4 py-2.5 text-xs text-muted">
127− <When at={v.effectiveAt} />
128− </td>
129− <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
130− {v.reason} · {v.createdBy}
131− </td>
132− </tr>
133− ))}
114+ {report.versions.map((v) => {
115+ const cells = versionCells(v);
116+ return (
117+ <tr key={v.id} className="border-b border-line align-top last:border-0">
118+ <td className="px-4 py-2.5 sm:px-5">
119+ <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
120+ {!v.appliedAt && (
121+ <span className="ml-2">
122+ <Badge tone="info">Coming</Badge>
123+ </span>
124+ )}
125+ {cells.note && <span className="block text-xs text-faint">{cells.note}</span>}
126+ </td>
127+ <td className="tabular px-4 py-2.5 text-right">{cells.cost === "—" ? <span className="text-faint">—</span> : cells.cost}</td>
128+ <td className="tabular px-4 py-2.5 text-right">{cells.price}</td>
129+ <td className="px-4 py-2.5 text-xs text-muted">
130+ <When at={v.effectiveAt} time />
131+ </td>
132+ <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
133+ {v.reason} · {v.createdBy}
134+ </td>
135+ </tr>
136+ );
137+ })}
134138 </tbody>
135139 </table>
136140 </div>
278282
279283 function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
280284 const rise = p.proposedCostMicros > p.currentCostMicros;
285+ const outcome = proposalOutcome(p);
281286 const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
282287 return (
283288 <li className="rounded-md border border-line px-4 py-3">
297302 <p className="mt-1 text-xs text-muted">{p.reason}</p>
298303 <p className="mt-1 text-xs text-faint">
299304 From the {p.source}, <When at={p.createdAt} time />
300− {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
301− {p.effectiveAt ? (
305+ {outcome ? ` · ${outcome}` : ""}
306+ {p.effectiveAt && p.status !== "superseded" ? (
302307 <>
303308 {" "}
304− · in force from <When at={p.effectiveAt} />
309+ · in force from <When at={p.effectiveAt} time />
305310 </>
306311 ) : null}
307312 {p.note ? ` · “${p.note}”` : ""}
+43−16
77 import { DaysChart } from "~/components/costs";
88 import { CostsHeader, chip, costsHref } from "~/components/costs-header";
99 import { Badge, Button, Field, Input, Notice, Section, Stat, When } from "~/components/ui";
10−import { capPercent, daySeries, marginTone, parseBucket, percentLabel, spendRows } from "~/lib/costs";
10+import { capPercent, daySeries, marginOnPrice, marginTone, parseBucket, percentLabel, spendRows, subscriptionsOver, whoPaid } from "~/lib/costs";
1111 import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
1212 import { usd } from "~/lib/money";
1313
1414 export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }];
1515
16+/** Billing's MARGIN_PERCENT: what is added to cost. */
17+const MARKUP_PERCENT = 20;
18+
1619 export const loader = ({ request, context }: Route.LoaderArgs) => costsLoader(request, context);
1720 export const action = ({ request, context }: Route.ActionArgs) => costsAction(request, context);
1821
2326 export default function Costs({ loaderData, actionData }: Route.ComponentProps) {
2427 const { range, report, error, done } = loaderData;
2528 const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null;
26− const description =
27− "What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus 20%; the bill itself, drift and the price book are on Bill & pricing.";
29+ const description = `What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus ${MARKUP_PERCENT}%, a ${percentLabel(marginOnPrice(MARKUP_PERCENT))} margin: every margin here is a share of the price, not of the cost. The bill itself, drift and the price book are on Bill & pricing.`;
2830 if (!report) {
2931 return (
3032 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
4648
4749 <Statement report={report} floor={floor} range={range} proposals={open.length} />
4850
49− <SpendSection caps={report.caps} error={failed?.section === "lift" ? failed.error : null} />
51+ <SpendSection caps={report.caps} range={range} error={failed?.section === "lift" ? failed.error : null} />
5052
5153 <Section
5254 className="mt-6"
7577 />
7678 </Section>
7779
78− <Section className="mt-6" title="By product" description={`Each of g1t's products over the last ${range} days. The floor is ${floor}%.`}>
80+ <Section
81+ className="mt-6"
82+ title="By product"
83+ description={`Each of g1t's products over the last ${range} days: what customers were charged at price, given away or not, against what it cost. The margin is the charge less the cost, as a share of the charge; at cost plus ${MARKUP_PERCENT}% it is ${percentLabel(marginOnPrice(MARKUP_PERCENT))}. The floor is ${floor}%.`}
84+ >
7985 <div className="-mx-4 overflow-x-auto sm:-mx-5">
8086 <table className="w-full min-w-[44rem] text-sm">
8187 <thead>
8591 <th className="px-4 py-2 text-right font-medium">Cost</th>
8692 <th className="px-4 py-2 text-right font-medium">Price book said</th>
8793 <th className="px-4 py-2 text-right font-medium">Margin</th>
88− <th className="px-4 py-2 text-right font-medium sm:pr-5">%</th>
94+ <th className="px-4 py-2 text-right font-medium sm:pr-5">% of price</th>
8995 </tr>
9096 </thead>
9197 <tbody>
142148 <tbody>
143149 {report.topWorkspaces.map((w) => {
144150 const net = w.revenueMicros - w.costMicros;
151+ // What was given away on purpose is a budget, not a loss:
152+ // red only for what neither paid nor was given.
153+ const lost = net + (w.givenMicros ?? 0) < 0;
145154 return (
146155 <tr key={w.workspace} className="border-b border-line last:border-0">
147156 <td className="px-4 py-2.5 sm:px-5">
153162 <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td>
154163 <td className="tabular px-4 py-2.5 text-right text-fg-soft">{usd(w.givenMicros ?? 0)}</td>
155164 <td className="tabular px-4 py-2.5 text-right">{usd(w.revenueMicros)}</td>
156− <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${net < 0 && !w.internal ? "text-danger" : "text-fg-soft"}`}>{usd(net)}</td>
165+ <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${lost && !w.internal ? "text-danger" : "text-fg-soft"}`}>{usd(net)}</td>
157166 </tr>
158167 );
159168 })}
204213 const usagePercent = soldSomething && o.usageMarginPercent !== undefined ? o.usageMarginPercent : null;
205214 const running = o.runningCostMicros ?? 0;
206215 const unmapped = o.unmappedCostMicros ?? 0;
207− // Cloudflare's subscriptions are not on the usage bill: the estimate, over the range.
208− const subscriptions = Math.round((report.caps.fixedMonthlyMicros * range) / 30);
216+ // Cloudflare's subscriptions are not on the usage bill: a month's, over the range.
217+ const subscriptions = subscriptionsOver(report.caps.fixedMonthlyMicros, range);
218+ const paid = whoPaid(o, subscriptions);
209219 const moneyIn = o.usageMicros + o.plansMicros;
210− const spent = o.costMicros + subscriptions;
220+ const spent = paid.totalMicros;
211221 const net = moneyIn - spent;
212222 const givenParts = [
213223 ["100% discounts", o.givenCompedMicros ?? 0],
272282 <Stat
273283 label="Margin on usage sold"
274284 value={percentLabel(usagePercent)}
275− hint={soldSomething ? `${usd(usageIn, { cents: true })} paid for usage that cost ${usd(usageCost, { cents: true })}` : "No usage sold in this range"}
285+ hint={
286+ soldSomething
287+ ? `Of the price: ${usd(usageIn, { cents: true })} paid for usage that cost ${usd(usageCost, { cents: true })}`
288+ : "No usage sold in this range"
289+ }
276290 tone={marginTone(usagePercent, floor)}
277291 />
278292 <Stat
282296 />
283297 <Stat
284298 label="Who g1t paid"
285− value={usd(o.costMicros, { cents: true })}
286− hint={`Cloudflare ${usd(o.cloudflareCostMicros ?? o.costMicros - (o.modelsCostMicros ?? 0), { cents: true })}, model providers ${usd(o.modelsCostMicros ?? 0, { cents: true })}`}
299+ value={usd(paid.totalMicros, { cents: true })}
300+ hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}, model providers ${usd(paid.modelsMicros, { cents: true })}; the cost in All in`}
287301 />
288302 <Stat
289303 label="Proposals waiting"
370384 );
371385 }
372386
373−function SpendSection({ caps, error }: { caps: CostsReport["caps"]; error: string | null }) {
387+function SpendSection({ caps, range, error }: { caps: CostsReport["caps"]; range: number; error: string | null }) {
374388 const { rows, totalMicros } = spendRows(caps);
375389 const net = caps.revenueMicros - totalMicros;
376390 return (
378392 className="mt-6"
379393 id="spend"
380394 title="g1t's own spend"
381− description="What g1t pays for itself, at cost: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and anything charged without real money behind it. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for."
395+ description="What g1t pays for itself this calendar month, today included: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and usage charged without real money behind it. Each is what the work cost g1t, never its price, counted as each charge settled: the model providers' cost, and the price book's cost of sandboxes and builds. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for."
382396 >
383397 <div className="grid gap-3 lg:grid-cols-2">
384398 <CapMeter
462476 <tr className="border-b border-line">
463477 <td className="px-4 py-2.5 sm:px-5">
464478 Money in
465− <span className="block text-xs text-faint">Usage paid for and the plan, reconciled through yesterday</span>
479+ <span className="block text-xs text-faint">Usage paid for with real money and the plan, as last reconciled</span>
466480 </td>
467481 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(caps.revenueMicros)}</td>
468482 </tr>
473487 </tbody>
474488 </table>
475489 </div>
490+ <p className="mt-3 text-xs text-muted">
491+ Why this differs from the statement above: this is {caps.month} so far, the statement the last {range} days. The statement takes Cloudflare&apos;s bill as the
492+ cost of what Cloudflare runs, so sandboxes and builds inside Cloudflare&apos;s included usage cost nothing there, and here what the price book says they
493+ cost. Here are Cloudflare&apos;s subscriptions for the whole month, there for the range.
494+ {(caps.resetMicros ?? 0) > 0 && (
495+ <>
496+ {" "}
497+ And {usd(caps.resetMicros ?? 0, { cents: true })} of this was spent on {(caps.resetWorkspaces ?? []).join(", ")} before a testing reset wiped
498+ {(caps.resetWorkspaces ?? []).length === 1 ? " its" : " their"} billing: still g1t&apos;s spend, but the statement has it only where the reset kept
499+ it, as given away.
500+ </>
501+ )}
502+ </p>
476503 </Section>
477504 );
478505 }
+16−0
34193419 pub created_by: String,
34203420 /// When the price book took it on; absent while it waits for its date.
34213421 pub applied_at: Option<String>,
3422+ /// What `cost_micros` is: `cost`, what g1t pays for a unit (a
3423+ /// provider's dollar passed on at cost is one); `rate`, a price g1t
3424+ /// sets with no cost behind it (the agent rate, security activation),
3425+ /// so its cost column is its price; `weight`, a multiplier in
3426+ /// millionths, not money (the agent rate's token weights).
3427+ #[serde(default)]
3428+ pub basis: String,
34223429 }
34233430
34243431 /// What a workspace cost g1t over the range, Cloudflare's costs shared
35903597 pub fixed_items: Vec<FixedCost>,
35913598 /// Money in this month, through the last reconciled day.
35923599 pub revenue_micros: i64,
3600+ /// Of this month's buckets, what was spent on workspaces whose billing
3601+ /// a testing reset later wiped: still g1t's spend, but no longer on
3602+ /// their ledger, so the reconciled figures have it only where the
3603+ /// reset kept it (as given away, testing resets).
3604+ #[serde(default)]
3605+ pub reset_micros: i64,
3606+ /// Those workspaces.
3607+ #[serde(default)]
3608+ pub reset_workspaces: Vec<String>,
35933609 }
35943610
35953611 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
+38−9
2121 | --- | --- | --- |
2222 | Billable usage, `GET /accounts/{account}/billable-usage?from=&to=` | One row per service per day in FOCUS columns: `ServiceFamilyName`, `ServiceName`, `ChargePeriodStart`, `PricingQuantity`, `ContractedCost` / `BilledCost` / `ListCost`. Every product g1t uses appears once it is used: Workers, Workers for Platforms, D1, KV, R2, Queues, Containers, Durable Objects, Artifacts, Browser Rendering, Workers AI, Vectorize, Cloudflare for SaaS, Email. Inside an included amount the cost is 0. | `cost_lines`, source `billable_usage` |
2323 | GraphQL `artifactsEventsAdaptiveGroups` | Artifacts' own count by `date`, `eventType` and `repositoryName`. Operations are `create`, `fork`, `push`, `pull`, `delete`; errors (`rateLimited`, `serverError`, …) are kept but not counted. | `cost_lines`, source `artifacts_events`; per workspace (from the store key `<workspace>--<repo>`; a pull request's working copy, `pulls--<id>`, is its repository's workspace's, from repos' `pull_owners`) in `own_counts` as `cloudflare_git` |
24−| GraphQL `aiGatewayRequestsAdaptiveGroups`, filtered to `AI_GATEWAY_ID` | What AI Gateway priced g1t's own provider traffic at, by `date`, `provider`, `model` and `wholesale`: `count`, `sum.cost` (dollars), `sum.tokensIn`/`tokensOut`/`cacheReadTokens`/`cacheWriteTokens`. Field names checked against Cloudflare's schema (introspection of `AccountAiGatewayRequestsAdaptiveGroups{Sum,Dimensions,Filter_InputObject}`). An adaptive (sampled) dataset: an estimate, close at g1t's volumes. Only g1t's hosted models go through this gateway: a workspace's own provider is called at its own address, never here. | `cost_lines`, source `ai_gateway`, product `ai_gateway_requests`: per day and model a line `<provider>_<model>` (requests, at the gateway's cost), and at no cost `…__tokens`, `…__cache_read_tokens`, `…__cache_write_tokens`; Cloudflare-billed (unified billing) requests are prefixed `wholesale__`. Mapped to `models` (migration 0036). A re-read day replaces all its gateway lines. |
24+| GraphQL `aiGatewayRequestsAdaptiveGroups`, filtered to `AI_GATEWAY_ID` | What AI Gateway priced g1t's own provider traffic at, by `date`, `provider` and `model`: `count`, `sum.cost` (dollars), `sum.tokensIn`/`tokensOut`/`cacheReadTokens`/`cacheWriteTokens`; asked twice in one query, filtered `wholesale: 0` and `wholesale: 1`. `wholesale` is never a dimension: grouped by it, Cloudflare answers no rows and no error (until 2026-10-08 that left the gateway's side empty while it had logged $11.11). Read over its own window: the last 31 days until it has answered with a line, then the last few. Field names checked against Cloudflare's schema (introspection of `AccountAiGatewayRequestsAdaptiveGroups{Sum,Dimensions,Filter_InputObject}`). An adaptive (sampled) dataset: an estimate, close at g1t's volumes. Only g1t's hosted models go through this gateway: a workspace's own provider is called at its own address, never here. | `cost_lines`, source `ai_gateway`, product `ai_gateway_requests`: per day and model a line `<provider>_<model>` (requests, at the gateway's cost), and at no cost `…__tokens`, `…__cache_read_tokens`, `…__cache_write_tokens`; Cloudflare-billed (unified billing) requests are prefixed `wholesale__`. Mapped to `models` (migration 0036). A re-read day replaces all its gateway lines. |
2525 | The ledger | Every charge: its cost at the price book's cost, what it was charged at price, what paid for it. | read, never written |
2626 | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` |
2727 | `plan_payments` | The plan's $20. | read |
4040 | Secret on g1t-billing | Permissions | Used for |
4141 | --- | --- | --- |
4242 | `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill, the Artifacts events and the subscriptions |
43−| `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with this token first and, if that is refused, with the bill's: Cloudflare answers a token without AI Gateway Read with no rows rather than an error, so the bill's token would read as a gateway that priced nothing |
43+| `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with this token first and, if that is refused, with the bill's (a token without Account Analytics Read gets an error). When the answer has no rows, billing asks the REST API for the gateway with the same token: 403 means the token lacks AI Gateway Read, 404 that `AI_GATEWAY_ID` names no gateway, 200 that nothing went through it; the `models` drift says which |
4444
4545 With neither, the daily run reconciles only what g1t counted itself, and
4646 the page says the bill cannot be read. Nothing fails. To set the scoped one:
9898 | `embeddings` | Workers AI, Vectorize | `context` |
9999 | `security` | (Workers CPU, under `platform`) | `security` |
100100 | `domains` | Cloudflare for SaaS | `domains` |
101−| `models` | not Cloudflare: AI Gateway's settled cost on the ledger; AI Gateway's own daily total (`ai_gateway_requests`) beside it, to check it | every other task (agent runs) |
101+| `models` | not Cloudflare: AI Gateway's settled cost on the ledger; AI Gateway's own daily total (`ai_gateway_requests`) beside it, to check it | every other task (agent runs). A planning run's ledger task is `plan`, the same as the plan's payments' key, so it is reconciled as `planning` (`margin::PLANNING_KEY`), which has no `revenue_map` row: models. Before 2026-10-08 its model cost landed in `platform`, where Cloudflare's bill is the cost, and was lost |
102102 | `platform` | Workers, D1, KV, Queues, Email, Browser Rendering, other Durable Objects | the plan's price |
103103
104104 For each day and bucket:
152152 each why by name, testing resets included (`givenResetMicros`).
153153 - **Month-end meters**: a day's figure is that day's `pending_days`
154154 snapshot less the day before's, within a month. Their month-end ledger
155− entries are left out, so nothing is counted twice.
156−- **Product margin** = (value − cost) / value.
155+ entries are left out, so nothing is counted twice. On a 100%-discount
156+ workspace the snapshot's charge is valued and given (comped), never cash:
157+ the month's close takes all of it off (`margin::comped_meter`).
158+- **Product margin** = (value − cost) / value: a share of the price, so
159+ cost plus 20% is a 16.7% margin. Sudo labels every margin "of price".
157160 - **Sudo's statement** keeps apart:
158161 - **Usage sold**: cash for usage against the cost of the usage buckets
159162 less what was given. Its margin is the headline; at cost plus 20% it
167170 - **Given away**: by why. A budget, watched under g1t's own spend, never
168171 shown as a loss.
169172 - **All in**: money in against all of it, with the figure without what
170− was given beside it. **Who g1t paid** splits the cost into Cloudflare
171− and the model providers.
173+ was given beside it. **Who g1t paid** is All in's cost, split into
174+ Cloudflare's usage, Cloudflare's subscriptions over the range, and the
175+ model providers.
172176
173177 The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash.
174178 - **Quantities**: where a mapping names an `own_meter`, Cloudflare's
190194 | --- | --- | --- |
191195 | Count | g1t's count and Cloudflare's differ by more than the mapping's `drift_percent` (10%) | Find out what Cloudflare counts: compare its events with `own_counts` `artifacts_*` and `cost_operations`. If it counts more (binding reads, `ls-refs`), either change repos' `operation_mapping` so customers are charged for what Cloudflare counts, or leave it and let the per-unit cost rise (below). |
192196 | Cost | Cloudflare charged more than `drift_percent` away from the price book's cost of the same usage, with at least `min_daily_cost` | A price is stale: check the proposals. |
193−| Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not) plus the model cost testing resets kept for those days (`reset_costs`), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement, it is a token that cannot see AI Gateway, or calls that went around it | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. A testing reset in the window is named in the detail: one that kept its cost says how much of the ledger's side it is; one from before resets kept their cost (the audit log has it, `reset_costs` does not) says the gateway's figure includes usage the ledger no longer has, so that part is not a leak, and the day it leaves the 7 days; while such a reset is in the window the `models` leak is not raised. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. |
197+| Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not) plus the model cost testing resets kept for those days (`reset_costs`), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement. Its detail says why as far as the run could tell: requests logged with no price (add the models' prices), a token Cloudflare refuses for the gateway (give it AI Gateway Read, or fix `AI_GATEWAY_ID`), a gateway the token sees with no requests (calls went around it), or a read that failed | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. A testing reset in the window is named in the detail: one that kept its cost says how much of the ledger's side it is; one from before resets kept their cost (the audit log has it, `reset_costs` does not) says the gateway's figure includes usage the ledger no longer has, so that part is not a leak, and the day it leaves the 7 days; while such a reset is in the window the `models` leak is not raised. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. |
194198 | Unpriced | Over the 7 days, a model in AI Gateway's analytics with tokens and $0 cost, or runs settled with `runs.gateway_note` (the gateway could not price all of a run) | The gateway has no price for a model g1t runs: add it in the gateway (custom cost) or route away from it. Until then those runs are charged no less than the sandbox reported (Claude Code's own price table), never $0 silently. |
195199 | Leak | Cost of at least `min_daily_cost` and nothing charged for it (never for `platform`), or a meter in `unmapped` | Map the meter (below), or decide it is overhead (`platform`). |
196200
218222 once per rise (`price_notices`), and the pricing page lists it with
219223 "takes effect". Rises are never retroactive; margin protection is for
220224 new usage once notice has run.
225+- A newer measurement before a rise's date replaces its version, with
226+ the new rise's own full notice; the proposal behind the replaced version
227+ is marked `superseded` (it never took effect, and nothing was charged at
228+ it). Sudo says "applied by guardrail, then replaced by a later
229+ measurement". Migration 0049 marks the ones from before: sandbox and
230+ build seconds' rises of 2026-10-07, replaced on 10-08.
221231 - Staff approve or reject in sudo. A rejection needs a note.
232+- **Price versions** in sudo show each version's cost and price, except
233+ where the cost column is not a cost (`PriceVersion.basis`,
234+ `pricing::basis_of`): the agent rate and security activation are rates
235+ g1t sets (`rate`, no cost shown), and the agent rate's token weights are
236+ multipliers (`weight`, shown as ×0.1). Dates show with their time: a
237+ version from 00:00 UTC is the evening before in the Americas.
222238
223239 ## Changing a mapping
224240
798814 | `unpaid` | Charged, but with no real money behind it: Stripe's test key, or `FREE_WHILE_BUILDING` |
799815
800816 The plan's included usage and on-demand charges count as revenue only
801−with live payments; in test mode they are `unpaid`. A workspace's own
817+with live payments; in test mode they are `unpaid`, at what the usage cost
818+g1t, never what was charged. A workspace's own
802819 model provider costs g1t nothing and is not counted. Month-end meters
803820 (git, storage, scans, embeddings, the cache) are not counted here; the
804821 daily reconciliation covers them. Migration `0024_spend_caps.sql`
805822 backfills the current month from the ledger.
806823
824+**Why it differs from the statement.** The section is the calendar month
825+so far, today included, counted as each charge settled: the model
826+providers' cost and the price book's cost of sandboxes and builds. The
827+statement is the range, reconciled against Cloudflare's bill, where
828+sandboxes inside Cloudflare's included usage cost $0. The section shows
829+Cloudflare's subscriptions for the whole month, the statement over the
830+range. And `g1t_spend` is never wiped: spend on a workspace a testing reset
831+wiped later stays here (sudo names it, `SpendCaps.reset_micros`), while the
832+statement has it only where the reset kept it (`reset_costs`, given away as
833+testing resets). syntaqx's $7.41 of 2026-10-02 to 10-05, reset on 10-07
834+before resets kept their cost, is that case.
835+
807836 ### Caps
808837
809838 | Cap | Variable (g1t-billing) | Default | At the cap |
+14−0
15931593 reason: string;
15941594 createdBy: string;
15951595 appliedAt: string | null;
1596+ /**
1597+ * What `costMicros` is: `cost`, what g1t pays for a unit; `rate`, a price
1598+ * g1t sets with no cost behind it (the agent rate), so it is no cost;
1599+ * `weight`, a multiplier in millionths, not money. Absent from older
1600+ * billing: read as `cost`.
1601+ */
1602+ basis?: "cost" | "rate" | "weight" | "";
15961603 };
15971604
15981605 /** What `resetBilling` removed. */
16981705 fixedItems?: { name: string; monthlyMicros: number }[];
16991706 /** Money in this month, through the last reconciled day. */
17001707 revenueMicros: number;
1708+ /**
1709+ * Of this month's buckets, what was spent on workspaces whose billing a
1710+ * testing reset later wiped (still g1t's spend; the reconciled figures
1711+ * have it only where the reset kept it), and those workspaces.
1712+ */
1713+ resetMicros?: number;
1714+ resetWorkspaces?: string[];
17011715 };
17021716
17031717 /** A comped account's monthly budget, at cost. */
+11−0
1+-- A proposal the guardrail applied as a rise waits for its notice as a
2+-- price version; a newer measurement before that date replaces the
3+-- version (src/pricing.rs, schedule_version). The proposal behind the
4+-- replaced version never took effect, but stayed "applied" with no date it
5+-- is in force from: sandbox_second and build_second's rises of 2026-10-07
6+-- (v2, due 2026-10-21) were replaced by v3 (due 2026-10-22) on 2026-10-08.
7+-- Nothing was charged at v2. From now on the replacement marks them
8+-- superseded; these are the ones from before.
9+UPDATE price_proposals SET status = 'superseded'
10+WHERE status = 'applied' AND version_id IS NOT NULL
11+ AND version_id NOT IN (SELECT id FROM price_versions);
+31−0
503503 .await?
504504 .and_then(|s| s.micros)
505505 .unwrap_or(0);
506+ // What a testing reset wiped from the ledger is still here.
507+ #[derive(Deserialize)]
508+ struct Reset {
509+ account: String,
510+ micros: Option<i64>,
511+ }
512+ let reset = self
513+ .db
514+ .prepare(RESET_SPEND_SQL)
515+ .bind(&[month_start.as_str().into()])?
516+ .all()
517+ .await?
518+ .results::<Reset>()?;
519+ let reset_micros = reset.iter().filter_map(|r| r.micros).sum();
520+ let reset_workspaces = reset.into_iter().map(|r| r.account.strip_prefix("ws_").unwrap_or(&r.account).to_owned()).collect();
506521 let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?;
507522 Ok(SpendCaps {
523+ reset_micros,
524+ reset_workspaces,
508525 day,
509526 month,
510527 today_micros,
550567 }
551568 }
552569
570+/// g1t's own spend since `?1` on accounts a testing reset wiped later than
571+/// the day it was spent (`admin_actions`, action `reset`), by account.
572+pub(crate) const RESET_SPEND_SQL: &str = "SELECT s.account, SUM(s.micros) AS micros FROM g1t_spend s
573+ WHERE s.day >= ?1 AND EXISTS (SELECT 1 FROM admin_actions a WHERE a.action = 'reset' AND a.account = s.account AND substr(a.created_at, 1, 10) >= s.day)
574+ GROUP BY s.account HAVING SUM(s.micros) > 0 ORDER BY s.account";
575+
553576 /// How sudo names a bucket of g1t's own spend.
554577 pub(crate) fn bucket_title(bucket: &str) -> &'static str {
555578 match bucket {
675698 }
676699
677700 #[test]
701+ fn spend_a_testing_reset_wiped_is_found_by_the_reset_after_it() {
702+ // syntaqx's $7.41 of 2026-10-02 to 10-05, reset on 10-07: still
703+ // g1t's spend, gone from its ledger.
704+ assert_eq!(crate::rename::parameters(RESET_SPEND_SQL), 1);
705+ assert!(RESET_SPEND_SQL.contains("a.action = 'reset'") && RESET_SPEND_SQL.contains("substr(a.created_at, 1, 10) >= s.day"));
706+ }
707+
708+ #[test]
678709 fn amounts_read_in_cents() {
679710 assert_eq!(cents(150_000_000), "$150.00");
680711 assert_eq!(cents(1_234_567), "$1.23");
+86−22
254254
255255 /// What AI Gateway priced each day's requests at, per provider and model,
256256 /// for g1t's gateway only: GraphQL `aiGatewayRequestsAdaptiveGroups`, with
257−/// `sum.cost` (dollars), the tokens it priced and whether Cloudflare billed
258−/// the request itself (`wholesale`). Field names checked against the
259−/// schema (`AccountAiGatewayRequestsAdaptiveGroupsSum` and `…Dimensions`).
257+/// `sum.cost` (dollars) and the tokens it priced; once for the requests
258+/// g1t's own provider keys paid (`wholesale: 0`) and once for those
259+/// Cloudflare billed itself (`wholesale: 1`). Field names checked against
260+/// the schema (`AccountAiGatewayRequestsAdaptiveGroupsSum`, `…Dimensions`
261+/// and `…Filter_InputObject`).
262+///
263+/// `wholesale` is a filter here, never a dimension: grouped by it,
264+/// Cloudflare answers no rows at all and no error. Seen 2026-10-08: with
265+/// `wholesale` in `dimensions` the query returned nothing for 562 requests
266+/// that cost $11.11, which it returns without it.
260267 pub(crate) const GATEWAY_QUERY: &str = "query ($account: String!, $gateway: String!, $since: Date!, $until: Date!) {
261268 viewer { accounts(filter: { accountTag: $account }) {
262− aiGatewayRequestsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until, gateway: $gateway }) {
269+ own: aiGatewayRequestsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until, gateway: $gateway, wholesale: 0 }) {
263270 count
264271 sum { cost tokensIn tokensOut cacheReadTokens cacheWriteTokens }
265− dimensions { date provider model wholesale }
272+ dimensions { date provider model }
266273 }
274+ wholesale: aiGatewayRequestsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until, gateway: $gateway, wholesale: 1 }) {
275+ count
276+ sum { cost tokensIn tokensOut cacheReadTokens cacheWriteTokens }
277+ dimensions { date provider model }
278+ }
267279 } }
268280 }";
269281
279291 if let Some(errors) = body["errors"].as_array().filter(|e| !e.is_empty()) {
280292 return Err(format!("AI Gateway analytics failed: {}", Value::Array(errors.clone())));
281293 }
282− let groups = body["data"]["viewer"]["accounts"][0]["aiGatewayRequestsAdaptiveGroups"].as_array().cloned().unwrap_or_default();
294+ let account = &body["data"]["viewer"]["accounts"][0];
295+ let groups = |alias: &str| account[alias].as_array().cloned().unwrap_or_default();
283296 let mut lines = Vec::new();
284− for g in &groups {
297+ for (wholesale, g) in groups("own").into_iter().map(|g| (false, g)).chain(groups("wholesale").into_iter().map(|g| (true, g))) {
285298 let d = &g["dimensions"];
286299 let Some(day) = d["date"].as_str().filter(|day| day.len() >= 10) else { continue };
287300 let provider = d["provider"].as_str().unwrap_or("unknown");
288301 let model = d["model"].as_str().unwrap_or("unknown");
289− let wholesale = d["wholesale"].as_u64().unwrap_or(0) == 1;
290302 let name = format!("{}{}", if wholesale { GATEWAY_WHOLESALE } else { "" }, slug(&format!("{provider} {model}")));
291303 let sum = |key: &str| g["sum"][key].as_f64().unwrap_or(0.0);
292304 let line = |meter: String, unit: &str, quantity: f64, cost_usd: f64| CostLine {
323335 pub wholesale_usd: f64,
324336 /// Runs settled with the gateway's figure short (see `keeper::settled_cost`).
325337 pub short_runs: u32,
338+ /// Requests the gateway logged, priced or not.
339+ pub requests: f64,
326340 }
327341
342+/// What the last read of AI Gateway's analytics found, so the models drift
343+/// can say why the gateway's side is empty rather than guess.
344+#[derive(Clone, Debug, Default, PartialEq)]
345+pub(crate) enum GatewayRead {
346+ /// Not read: no `AI_GATEWAY_ID`, or no token to read it with.
347+ #[default]
348+ NotRead,
349+ /// GraphQL refused it, or did not answer.
350+ Failed(String),
351+ /// It answered with requests.
352+ Rows,
353+ /// It answered with no rows. `visible`: whether the token it was read
354+ /// with can see the gateway (`GET …/ai-gateway/gateways/{id}`: 403
355+ /// without AI Gateway Read, 404 for an id that is not there), to tell
356+ /// that from a gateway nothing went through; None when that could not
357+ /// be told.
358+ Empty { visible: Option<bool> },
359+}
360+
328361 /// The caveats in AI Gateway's lines (any days, any order).
329362 pub(crate) fn gateway_caveats(lines: &[(String, f64, f64)]) -> GatewayCaveats {
330363 let mut out = GatewayCaveats::default();
338371 } else if meter.ends_with(GATEWAY_CACHE_WRITE) {
339372 out.cache_write_tokens += quantity;
340373 } else {
374+ out.requests += quantity;
341375 *cost.entry(meter.as_str()).or_default() += cost_usd;
342376 if meter.starts_with(GATEWAY_WHOLESALE) {
343377 out.wholesale_usd += cost_usd;
543577 impl Billing {
544578 /// Reads Cloudflare's bill for the days due (see `window`) into
545579 /// `cost_lines`: the days read and how many lines. None without a
546− /// token. What could not be read is added to `problems`.
547− pub(crate) async fn read_cloudflare(&self, keeper: &Keeper, problems: &mut Vec<String>) -> Result<Option<(String, String, u32)>> {
580+ /// token. What could not be read is added to `problems`, and what AI
581+ /// Gateway's analytics answered to `gateway`.
582+ pub(crate) async fn read_cloudflare(
583+ &self,
584+ keeper: &Keeper,
585+ problems: &mut Vec<String>,
586+ gateway: &mut GatewayRead,
587+ ) -> Result<Option<(String, String, u32)>> {
548588 if !keeper.can_read_bill() {
549589 return Ok(None);
550590 }
579619 }
580620 // What AI Gateway priced g1t's own provider traffic at, each day:
581621 // the total the ledger's model cost is checked against (`margin`).
622+ // Over its own window: until it has answered with a line, the 31
623+ // days GraphQL keeps, whatever the bill's window is.
582624 if !keeper.gateway().is_empty() {
625+ let last = self
626+ .db
627+ .prepare("SELECT MAX(day) AS day FROM cost_lines WHERE source = ?")
628+ .bind(&[SOURCE_GATEWAY.into()])?
629+ .first::<Last>(None)
630+ .await?
631+ .and_then(|l| l.day);
632+ let (from, to) = window(last.as_deref(), now_ms());
583633 match keeper
584− .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &since, &until))
634+ .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &from, &to))
585635 .await
586636 .map_err(|e| e.to_string())
587637 .and_then(|body| lines_from_gateway(&body))
591641 // re-read day must not keep its old line.
592642 self.db
593643 .prepare("DELETE FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3")
594− .bind(&[SOURCE_GATEWAY.into(), since.as_str().into(), until.as_str().into()])?
644+ .bind(&[SOURCE_GATEWAY.into(), from.as_str().into(), to.as_str().into()])?
595645 .run()
596646 .await?;
647+ *gateway = if lines.is_empty() { GatewayRead::Empty { visible: keeper.gateway_visible().await } } else { GatewayRead::Rows };
597648 written += self.upsert_lines(&lines, &fetched_at).await?;
598649 }
599− Err(error) => problems.push(format!("AI Gateway's analytics could not be read: {error}")),
650+ Err(error) => {
651+ *gateway = GatewayRead::Failed(error.clone());
652+ problems.push(format!("AI Gateway's analytics could not be read: {error}"));
653+ }
600654 }
601655 }
602656 Ok(Some((since, until, written)))
784838 /// AI Gateway's analytics in the shape of the schema
785839 /// (`aiGatewayRequestsAdaptiveGroups`: `count`, `sum`, `dimensions`).
786840 fn gateway_fixture() -> Value {
787− let group = |day: &str, provider: &str, model: &str, wholesale: u8, count: u64, cost: f64, tokens: (f64, f64, f64, f64)| {
841+ let group = |day: &str, provider: &str, model: &str, count: u64, cost: f64, tokens: (f64, f64, f64, f64)| {
788842 json!({
789843 "count": count,
790844 "sum": { "cost": cost, "tokensIn": tokens.0, "tokensOut": tokens.1, "cacheReadTokens": tokens.2, "cacheWriteTokens": tokens.3 },
791− "dimensions": { "date": day, "provider": provider, "model": model, "wholesale": wholesale }
845+ "dimensions": { "date": day, "provider": provider, "model": model }
792846 })
793847 };
794− json!({ "data": { "viewer": { "accounts": [{ "aiGatewayRequestsAdaptiveGroups": [
795− group("2026-10-05", "anthropic", "claude-sonnet-5-5", 0, 120, 4.25, (900_000.0, 40_000.0, 3_000_000.0, 200_000.0)),
796− group("2026-10-05", "anthropic", "claude-haiku-4-5-20251001", 0, 300, 0.40, (400_000.0, 20_000.0, 0.0, 0.0)),
797− group("2026-10-06", "anthropic", "claude-new-1", 0, 12, 0.0, (80_000.0, 4_000.0, 0.0, 0.0)),
798− group("2026-10-06", "openai", "gpt-x", 1, 5, 0.10, (1_000.0, 100.0, 0.0, 0.0)),
799− ] }] } }, "errors": null })
848+ json!({ "data": { "viewer": { "accounts": [{
849+ "own": [
850+ group("2026-10-05", "anthropic", "claude-sonnet-5-5", 120, 4.25, (900_000.0, 40_000.0, 3_000_000.0, 200_000.0)),
851+ group("2026-10-05", "anthropic", "claude-haiku-4-5-20251001", 300, 0.40, (400_000.0, 20_000.0, 0.0, 0.0)),
852+ group("2026-10-06", "anthropic", "claude-new-1", 12, 0.0, (80_000.0, 4_000.0, 0.0, 0.0)),
853+ ],
854+ "wholesale": [group("2026-10-06", "openai", "gpt-x", 5, 0.10, (1_000.0, 100.0, 0.0, 0.0))],
855+ }] } }, "errors": null })
800856 }
801857
802858 #[test]
803859 fn the_gateway_query_names_the_fields_its_schema_has() {
804860 // As checked against Cloudflare's GraphQL schema (introspection of
805861 // AccountAiGatewayRequestsAdaptiveGroups{,Sum,Dimensions,Filter}).
806− for field in ["aiGatewayRequestsAdaptiveGroups", "date_geq", "date_leq", "gateway: $gateway", "count", "cost", "tokensIn", "tokensOut", "cacheReadTokens", "cacheWriteTokens", "date", "provider", "model", "wholesale"] {
862+ for field in ["aiGatewayRequestsAdaptiveGroups", "date_geq", "date_leq", "gateway: $gateway", "wholesale: 0", "wholesale: 1", "count", "cost", "tokensIn", "tokensOut", "cacheReadTokens", "cacheWriteTokens", "date", "provider", "model"] {
807863 assert!(GATEWAY_QUERY.contains(field), "{field}");
808864 }
865+ // Grouped by `wholesale`, Cloudflare answers no rows and no error:
866+ // it is only ever a filter.
867+ for dimensions in GATEWAY_QUERY.split("dimensions {").skip(1) {
868+ let dimensions = &dimensions[..dimensions.find('}').unwrap()];
869+ assert!(!dimensions.contains("wholesale"), "{dimensions}");
870+ }
809871 let body = gateway_variables("acct", "g1t", "2026-10-01", "2026-10-07");
810872 assert_eq!(body["variables"]["gateway"], "g1t");
811873 }
835897 assert_eq!(caveats.unpriced, vec!["anthropic_claude_new_1".to_owned()]);
836898 assert_eq!((caveats.cache_read_tokens, caveats.cache_write_tokens), (3_000_000.0, 200_000.0));
837899 assert!((caveats.wholesale_usd - 0.10).abs() < 1e-9);
900+ // Every request it logged, priced or not, from both answers.
901+ assert_eq!(caveats.requests, 437.0);
838902 // A model priced on one day and not another is priced.
839903 let mixed = vec![
840904 ("m".to_owned(), 3.0, 0.5),
+5−1
283283 }
284284
285285 /// The key a usage line is reconciled under, as `margin::usage_rows` reads
286−/// it: builds apart from a deployment's requests.
286+/// it: builds apart from a deployment's requests, and an agent's planning
287+/// run apart from the plan's payments (`margin::PLANNING_KEY`).
287288 pub(crate) fn usage_key(task: Option<&str>, reference: &str) -> String {
288289 match task {
289290 Some("deployments") if reference.starts_with("deploy/") => "builds".to_owned(),
291+ Some("plan") => crate::margin::PLANNING_KEY.to_owned(),
290292 Some(task) => task.to_owned(),
291293 None => "other".to_owned(),
292294 }
11401142 assert_eq!(usage_key(Some("deployments"), "deploy/abc"), "builds");
11411143 assert_eq!(usage_key(Some("deployments"), "requests/2026-10"), "deployments");
11421144 assert_eq!(usage_key(Some("implement"), "run_1"), "implement");
1145+ // An agent's planning run, never the plan's payments.
1146+ assert_eq!(usage_key(Some("plan"), "run_2"), "planning");
11431147 assert_eq!(usage_key(None, "crd_a"), "other");
11441148 }
11451149
+24−4
116116 }
117117
118118 /// A GraphQL query over AI Gateway's analytics: with the keeper's token
119− /// (AI Gateway Read) first, and on failure with the bill's. Not the
120− /// other way round: Cloudflare answers a token that cannot see AI
121− /// Gateway with no rows, not an error, so the bill's token would read
122− /// as a gateway that priced nothing.
119+ /// (AI Gateway Read) first, and on failure with the bill's. A token
120+ /// without Account Analytics Read gets an error ("caller does not hold
121+ /// any of the required permissions for this dataset"); when the answer
122+ /// has no rows, `gateway_visible` tells a token that cannot see the
123+ /// gateway from a gateway nothing went through.
123124 pub(crate) async fn gateway_graphql(&self, body: Value) -> Result<Value> {
124125 let Some(token) = &self.token else {
125126 return self.graphql(body).await;
133134 }
134135 }
135136
137+ /// Whether the token AI Gateway's analytics are read with can see the
138+ /// gateway: the REST API answers 403 for a token without AI Gateway
139+ /// Read and 404 for a gateway id that is not there. None when the
140+ /// answer says neither (Cloudflare down, no token).
141+ pub(crate) async fn gateway_visible(&self) -> Option<bool> {
142+ let token = self.token.as_deref().or(self.billing_token.as_deref())?;
143+ match send_with(token, Method::Get, &self.api(&format!("/ai-gateway/gateways/{}", self.gateway)), None).await {
144+ Ok(_) => Some(true),
145+ Err(error) => refused(&error.to_string()).then_some(false),
146+ }
147+ }
148+
136149 /// What AI Gateway priced a session's requests at, and how many there
137150 /// were, with the requests it had no price for.
138151 async fn session_cost(&self, session: &str) -> Result<SessionCost> {
200213 }
201214 }
202215
216+/// Whether an error from `send_with` is Cloudflare saying no to the token
217+/// (401, 403) or that there is no such thing for it (404), rather than
218+/// failing.
219+pub(crate) fn refused(error: &str) -> bool {
220+ ["Cloudflare answered 401", "Cloudflare answered 403", "Cloudflare answered 404"].iter().any(|s| error.contains(s))
221+}
222+
203223 /// A request to Cloudflare's API with a bearer token; anything but 200 is
204224 /// an error with what Cloudflare said.
205225 async fn send_with(token: &str, method: Method, url: &str, body: Option<Value>) -> Result<Value> {
+97−20
4444 pub(crate) const OVERHEAD: [&str; 1] = ["platform"];
4545 /// Buckets Cloudflare does not bill: their cost is g1t's own figure.
4646 pub(crate) const NOT_CLOUDFLARE: [&str; 1] = ["models"];
47+/// The key an agent's planning run is reconciled under. Its ledger task
48+/// is `plan`, which is also the plan's payments' key (`revenue_map`: the
49+/// platform bucket), so read as `plan` its model cost went to running g1t,
50+/// where Cloudflare's bill is the cost, and was lost. No `revenue_map`
51+/// row: models, like every agent run.
52+pub(crate) const PLANNING_KEY: &str = "planning";
4753 /// The days drift is judged over.
4854 const DRIFT_DAYS: u64 = 7;
4955 /// The days a workspace's cost is set against its revenue.
432438 out
433439 }
434440
441+/// A month-end meter's day on a 100%-discount workspace: all of it given
442+/// (comped) and none of it money in. The snapshot holds what the month
443+/// would charge before the discount, which the month's close takes off in
444+/// full; counted as paid, flagon-io's cache, embeddings and scans read as
445+/// money in ($0.0023 on 2026-10-08).
446+pub(crate) fn comped_meter(mut u: UsageRow) -> UsageRow {
447+ u.given = Given { comped: u.value, ..Given::default() };
448+ u.cash = 0;
449+ u
450+}
451+
435452 /// Margin as a share of what was charged, in percent; None when nothing was.
436453 pub(crate) fn margin_percent(revenue_micros: i64, cost_micros: i64) -> Option<f64> {
437454 (revenue_micros > 0).then(|| (revenue_micros - cost_micros) as f64 * 100.0 / revenue_micros as f64)
676693
677694 /// The models drift's detail: the gateway's total against the ledger's,
678695 /// and any testing reset in the window.
679−pub(crate) fn models_detail(drift: &Drift, caveats: &costs::GatewayCaveats, resets: &[ResetNote]) -> String {
696+pub(crate) fn models_detail(drift: &Drift, caveats: &costs::GatewayCaveats, resets: &[ResetNote], read: &costs::GatewayRead) -> String {
680697 if drift.cloudflare <= 0.0 {
681− return format!(
682− "Models: the ledger's model cost is {} over the last {DRIFT_DAYS} days and AI Gateway priced nothing, so the two were not compared. Either the gateway's analytics cannot be seen (Cloudflare answers a token without AI Gateway: Read with no rows, not an error; billing reads them with CLOUDFLARE_USAGE_TOKEN, then CLOUDFLARE_BILLING_TOKEN), or model calls went around the gateway.",
698+ let head = format!(
699+ "Models: the ledger's model cost is {} over the last {DRIFT_DAYS} days and AI Gateway priced nothing, so the two were not compared.",
683700 dollars(drift.ours as i64)
684701 );
702+ let why = if caveats.requests > 0.0 {
703+ format!(
704+ "The gateway logged {} requests in those days but put no price on them: it has no price for the models used{}. Add their prices to the gateway, or route those models where they are priced.",
705+ crate::features::thousands(caveats.requests.round() as u64),
706+ if caveats.unpriced.is_empty() { String::new() } else { format!(" ({})", caveats.unpriced.join(", ")) }
707+ )
708+ } else {
709+ match read {
710+ costs::GatewayRead::Empty { visible: Some(false) } => "The token billing reads AI Gateway with (CLOUDFLARE_USAGE_TOKEN, else CLOUDFLARE_BILLING_TOKEN) cannot see the gateway named in AI_GATEWAY_ID: Cloudflare refused it (no Account, AI Gateway, Read on the token, or no gateway by that id). Give the token AI Gateway Read, or fix AI_GATEWAY_ID.".to_owned(),
711+ costs::GatewayRead::Empty { visible: Some(true) } => "The token can see the gateway and it logged no requests in those days: model calls went around it. Check that every caller of a hosted model uses the gateway's URL (services/models, the runner's ANTHROPIC_BASE_URL).".to_owned(),
712+ costs::GatewayRead::Failed(error) => format!("AI Gateway's analytics could not be read: {error}"),
713+ costs::GatewayRead::NotRead => "AI Gateway was not read on this run: no AI_GATEWAY_ID, or no CLOUDFLARE_USAGE_TOKEN or CLOUDFLARE_BILLING_TOKEN.".to_owned(),
714+ costs::GatewayRead::Rows | costs::GatewayRead::Empty { visible: None } => "The gateway answered without requests for these days, and whether its token can see the gateway could not be told: either the token lacks AI Gateway Read, or model calls went around the gateway.".to_owned(),
715+ }
716+ };
717+ return format!("{head} {why}");
685718 }
686719 let lower = drift.ours < drift.cloudflare;
687720 let wiped = resets.iter().any(|r| !r.recorded);
10161049 /// day has come, and raise or clear alerts.
10171050 pub(crate) async fn costs_daily(&self, env: &Env, keeper: &crate::keeper::Keeper) -> Result<CostsRun> {
10181051 let mut run = CostsRun::default();
1019− let (since, until) = match self.read_cloudflare(keeper, &mut run.problems).await? {
1052+ let mut gateway = costs::GatewayRead::default();
1053+ let (since, until) = match self.read_cloudflare(keeper, &mut run.problems, &mut gateway).await? {
10201054 Some((since, until, lines)) => {
10211055 run.lines = lines;
10221056 (since, until)
10481082 let window = day_before(&until, costs::BACKFILL_DAYS - 1);
10491083 let reconcile_from = if window < since { window } else { since.clone() };
10501084 run.days = self.reconcile_range(&reconcile_from, &until).await?;
1051− let drift = self.find_drift(&until).await?;
1085+ let drift = self.find_drift(&until, &gateway).await?;
10521086 run.proposals = self.measure_units(&until).await?;
10531087 self.apply_due_versions().await?;
10541088 run.alerts = self.raise_alerts(env, &until, &drift).await?;
11021136 .db
11031137 .prepare(format!(
11041138 "SELECT substr(created_at, 1, 10) AS day, workspace,
1105− CASE WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds' ELSE COALESCE(task, 'other') END AS key,
1139+ CASE WHEN task = 'deployments' AND reference LIKE 'deploy/%' THEN 'builds'
1140+ WHEN task = 'plan' THEN '{planning}' ELSE COALESCE(task, 'other') END AS key,
11061141 CASE WHEN workspace IN ({internal}) THEN 1 ELSE 0 END AS internal,
11071142 CASE WHEN billed_to = 'workspace' THEN 1 ELSE 0 END AS own_provider,
11081143 -SUM(amount_micros) AS cash,
11161151 WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND COALESCE(task, '') NOT IN ({charged_here})
11171152 AND (?3 = '' OR workspace = ?3)
11181153 GROUP BY 1, 2, 3, 4, 5",
1119− internal = crate::sales::INTERNAL_SQL
1154+ internal = crate::sales::INTERNAL_SQL,
1155+ planning = PLANNING_KEY
11201156 ))
11211157 .bind(&[since.into(), end.as_str().into(), only_sql.into()])?
11221158 .all()
11771213 .filter(|s| crate::storage::CHARGED_HERE.contains(&s.source.as_str()) || s.source == "domains")
11781214 .map(|s| (s.day, s.workspace, s.source, s.cost_micros, s.charge_micros))
11791215 .collect::<Vec<_>>();
1180− out.extend(pending_deltas(&snaps).into_iter().filter(|u| u.day.as_str() >= since).map(|mut u| {
1181− if internal.contains(&u.workspace) {
1182− u.given = Given { comped: u.value, ..Given::default() };
1183− }
1184− u
1185− }));
1216+ out.extend(
1217+ pending_deltas(&snaps)
1218+ .into_iter()
1219+ .filter(|u| u.day.as_str() >= since)
1220+ .map(|u| if internal.contains(&u.workspace) { comped_meter(u) } else { u }),
1221+ );
11861222 // The plan's price, spread over the 30 days it pays for, so a month's
11871223 // payment does not read as one very good day and 29 bad ones.
11881224 #[derive(Deserialize)]
14501486
14511487 /// Drift over the last week, written to `cost_drift` (replacing the
14521488 /// last run's), with unmapped Cloudflare meters as leaks.
1453− async fn find_drift(&self, until: &str) -> Result<Vec<(Drift, String)>> {
1489+ async fn find_drift(&self, until: &str, read: &costs::GatewayRead) -> Result<Vec<(Drift, String)>> {
14541490 let since = day_before(until, DRIFT_DAYS - 1);
14551491 let settings = self.cost_settings().await?;
14561492 let rules = self.rules().await?;
14761512 }
14771513 let title = costs::bucket_title(bucket);
14781514 let detail = match drift.kind {
1479− DriftKind::Cost if NOT_CLOUDFLARE.contains(&bucket.as_str()) => models_detail(&drift, &caveats, &resets),
1515+ DriftKind::Cost if NOT_CLOUDFLARE.contains(&bucket.as_str()) => models_detail(&drift, &caveats, &resets, read),
14801516 DriftKind::Count => format!(
14811517 "{title}: g1t counted {}, Cloudflare {} over the last {DRIFT_DAYS} days ({:+.1}%). Customers are charged for what g1t counts; check what Cloudflare counts as a unit and change the repos service's operation_mapping (set_operation_mapping).",
14821518 crate::features::thousands(drift.ours.max(0.0).round() as u64),
22462282 }
22472283
22482284 #[test]
2285+ fn a_planning_run_is_model_cost_not_running_g1t() {
2286+ // flagon-io's planning run on 2026-10-07 cost $0.0748 of model
2287+ // calls; read under the ledger's task, `plan`, it went to running
2288+ // g1t, where Cloudflare's bill is the cost, and the model cost was
2289+ // lost from the statement and the drift.
2290+ let usage = vec![
2291+ usage("2026-10-07", "flagon-io", PLANNING_KEY, 89_741, 0, 74_784),
2292+ usage("2026-10-07", "acme", "plan", 666_666, 666_666, 0),
2293+ ];
2294+ let (days, _) = fold(&rules(), &revenue_map(), &[], &[], &usage, &BTreeSet::new());
2295+ let get = |bucket: &str| days.iter().find(|d| d.bucket == bucket).unwrap();
2296+ assert_eq!((get("models").cost(), get("models").value_micros), (74_784, 89_741));
2297+ assert_eq!((get("platform").own_cost_micros, get("platform").cash_micros), (0, 666_666));
2298+ assert!(!revenue_map().contains_key(PLANNING_KEY));
2299+ }
2300+
2301+ #[test]
2302+ fn a_comped_workspaces_month_end_meters_are_given_never_money_in() {
2303+ let snap = |day: &str, cost: i64, charge: i64| (day.to_string(), "flagon-io".to_string(), "cache".to_string(), cost, charge);
2304+ let rows: Vec<UsageRow> = pending_deltas(&[snap("2026-10-07", 1, 2), snap("2026-10-08", 473, 568)]).into_iter().map(comped_meter).collect();
2305+ assert_eq!(rows.iter().map(|r| (r.cash, r.value, r.given.comped)).collect::<Vec<_>>(), vec![(0, 2, 2), (0, 566, 566)]);
2306+ let internal: BTreeSet<String> = ["flagon-io".to_string()].into();
2307+ let (days, workspaces) = fold(&rules(), &revenue_map(), &[], &[], &rows, &internal);
2308+ assert!(days.iter().all(|d| d.cash_micros == 0));
2309+ assert!(workspaces.iter().all(|w| w.revenue == 0));
2310+ }
2311+
2312+ #[test]
22492313 fn artifacts_events_count_when_the_bill_does_not() {
22502314 let lines = vec![
22512315 line("2026-10-05", SOURCE_ARTIFACTS, "artifacts", "events_pull", 120.0, 0.0),
24772541 // rows), so it is said. Under the minimum, or no model cost: nothing.
24782542 let silent = drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000);
24792543 assert_eq!(silent, vec![Drift { bucket: "models".into(), kind: DriftKind::Cost, ours: 1_000_000.0, cloudflare: 0.0, delta_percent: None }]);
2480− let said = models_detail(&silent[0], &costs::GatewayCaveats::default(), &[]);
2481− assert!(said.contains("$1.00") && said.contains("priced nothing") && said.contains("AI Gateway: Read"), "{said}");
2544+ // Why it is empty, as far as the run could tell.
2545+ let why = |caveats: &costs::GatewayCaveats, read: costs::GatewayRead| models_detail(&silent[0], caveats, &[], &read);
2546+ let none = costs::GatewayCaveats::default();
2547+ let said = why(&none, costs::GatewayRead::Empty { visible: Some(false) });
2548+ assert!(said.contains("$1.00") && said.contains("priced nothing") && said.contains("cannot see the gateway") && said.contains("AI Gateway Read"), "{said}");
2549+ let said = why(&none, costs::GatewayRead::Empty { visible: Some(true) });
2550+ assert!(said.contains("logged no requests") && said.contains("went around it"), "{said}");
2551+ let said = why(&none, costs::GatewayRead::Failed("Cloudflare answered 500".into()));
2552+ assert!(said.contains("could not be read: Cloudflare answered 500"), "{said}");
2553+ assert!(why(&none, costs::GatewayRead::NotRead).contains("not read on this run"));
2554+ assert!(why(&none, costs::GatewayRead::Empty { visible: None }).contains("could not be told"));
2555+ // Requests with no price: neither the token nor a bypass.
2556+ let unpriced = costs::GatewayCaveats { requests: 42.0, unpriced: vec!["anthropic_claude_new_1".into()], ..Default::default() };
2557+ let said = why(&unpriced, costs::GatewayRead::Rows);
2558+ assert!(said.contains("logged 42 requests") && said.contains("no price for the models used (anthropic_claude_new_1)"), "{said}");
24822559 assert!(drifts("models", &[day("models", 0, 50_000, 60_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty());
24832560 assert!(drifts("models", &[day("models", 0, 0, 0, 0.0, 0.0)], 10.0, false, 100_000).is_empty());
24842561 // The detail says which way and why it may be off.
24852562 let caveats = costs::GatewayCaveats { cache_read_tokens: 3_000_000.0, unpriced: vec!["anthropic_claude_new_1".into()], ..Default::default() };
2486− let detail = models_detail(&short[0], &caveats, &[]);
2563+ let detail = models_detail(&short[0], &caveats, &[], &costs::GatewayRead::default());
24872564 assert!(detail.contains("$5.00") && detail.contains("$3.00") && detail.contains("were not charged"), "{detail}");
24882565 assert!(detail.contains("3,000,000 prompt-cache read") && detail.contains("no price for anthropic_claude_new_1"), "{detail}");
24892566 }
26232700 ]
26242701 );
26252702 let drift = Drift { bucket: "models".into(), kind: DriftKind::Cost, ours: 2_490_000.0, cloudflare: 11_110_000.0, delta_percent: Some(-77.6) };
2626− let detail = models_detail(&drift, &costs::GatewayCaveats::default(), &notes);
2703+ let detail = models_detail(&drift, &costs::GatewayCaveats::default(), &notes, &costs::GatewayRead::default());
26272704 assert!(detail.contains("includes model usage wiped by a testing reset of syntaqx on 2026-10-07"), "{detail}");
26282705 assert!(detail.contains("not a leak") && detail.contains("leaves the 7 days on 2026-10-14"), "{detail}");
26292706 assert!(!detail.contains("a gap that stays is a leak"), "{detail}");
26342711 assert!(!wiped_not_leaked(&leak, &notes[1..]));
26352712 assert!(!wiped_not_leaked(&Drift { bucket: "actions_cache".into(), ..leak }, &notes));
26362713 // No reset: the detail is as before.
2637− assert!(models_detail(&drift, &costs::GatewayCaveats::default(), &[]).contains("a gap that stays is a leak"));
2714+ assert!(models_detail(&drift, &costs::GatewayCaveats::default(), &[], &costs::GatewayRead::default()).contains("a gap that stays is a leak"));
26382715 }
26392716 }
+55−4
6262 /// percent or two at a time, and an email for each would be noise.
6363 const EMAIL_RISE_PERCENT: f64 = 5.0;
6464
65+/// What a meter's `cost_micros` is (`PriceVersion::basis`): a weight of the
66+/// agent rate's tokens is a multiplier; the agent rate and security
67+/// activation are prices g1t sets, with no per-unit cost behind them (the
68+/// models a run uses are charged apart, at cost); everything else is a
69+/// cost g1t pays.
70+pub(crate) fn basis_of(meter: &str) -> &'static str {
71+ if meter.starts_with("agent_token_weight_") {
72+ "weight"
73+ } else if matches!(meter, "agent_tokens" | "agent_tokens_own") || meter == crate::features::SECURITY_METER {
74+ "rate"
75+ } else {
76+ "cost"
77+ }
78+}
79+
80+/// Marks the proposals behind a meter's versions still waiting for their
81+/// date as superseded, before a newer version replaces them; who decided
82+/// it, and when, stay. Parameter: the meter.
83+pub(crate) const SUPERSEDE_WAITING_SQL: &str = "UPDATE price_proposals SET status = 'superseded'
84+ WHERE version_id IN (SELECT id FROM price_versions WHERE meter = ?1 AND applied_at IS NULL)";
85+
6586 /// Owners told of rises per run, at most; the rest on the next run.
6687 const NOTICES_PER_RUN: usize = 40;
6788
403424 + 1;
404425 let id = format!("pv_{meter}_{next}");
405426 let now = rfc3339(now_ms());
406− // A newer decision replaces a rise still waiting for its date.
427+ // A newer decision replaces a rise still waiting for its date. The
428+ // proposal behind it never took effect: superseded, not "applied"
429+ // with no date it is in force from.
407430 self.db
408− .prepare("DELETE FROM price_versions WHERE meter = ? AND applied_at IS NULL")
409− .bind(&[meter.into()])?
410− .run()
431+ .batch(vec![
432+ self.db.prepare(SUPERSEDE_WAITING_SQL).bind(&[meter.into()])?,
433+ self.db.prepare("DELETE FROM price_versions WHERE meter = ? AND applied_at IS NULL").bind(&[meter.into()])?,
434+ ])
411435 .await?;
412436 self.db
413437 .prepare(
524548 .into_iter()
525549 .map(|v| PriceVersion {
526550 price_micros: Price::price_for(v.cost_micros, v.markup_percent),
551+ basis: basis_of(&v.meter).to_owned(),
527552 id: v.id,
528553 meter: v.meter,
529554 version: v.version,
757782 }
758783
759784 #[test]
785+ fn a_versions_cost_says_what_it_is() {
786+ // The agent rate's $0.25 a million tokens is g1t's price, not what
787+ // tokens cost g1t; the weights are multipliers.
788+ assert_eq!(basis_of("agent_tokens"), "rate");
789+ assert_eq!(basis_of("agent_tokens_own"), "rate");
790+ assert_eq!(basis_of("security_activation"), "rate");
791+ assert_eq!(basis_of("agent_token_weight_cache_read"), "weight");
792+ // A provider's dollar passed on at cost, and Cloudflare's units, are costs.
793+ for meter in ["agent_models", "gateway_models", "sandbox_second", "git_operations", "card_fee_percent"] {
794+ assert_eq!(basis_of(meter), "cost", "{meter}");
795+ }
796+ }
797+
798+ #[test]
799+ fn a_rise_replaced_before_its_date_is_superseded_not_applied() {
800+ // 2026-10-07's sandbox rise (v2, due 10-21) was replaced by
801+ // 10-08's (v3, due 10-22): v2's proposal never took effect.
802+ let sql = SUPERSEDE_WAITING_SQL;
803+ assert_eq!(crate::rename::parameters(sql), 1);
804+ assert!(sql.contains("status = 'superseded'") && sql.contains("applied_at IS NULL") && !sql.contains("decided_"), "{sql}");
805+ // A rise still waits its notice: the replacement is no shortcut.
806+ let Decision::Auto { effective_ms } = decide(18.1817, 19.5181, guard(), now(), false) else { panic!() };
807+ assert_eq!(rfc3339(effective_ms), "2026-10-20T04:17:00.000Z");
808+ }
809+
810+ #[test]
760811 fn past_the_guardrail_or_with_auto_off_staff_decide() {
761812 // Up 50%: staff.
762813 assert_eq!(decide(150_000.0, 225_000.0, guard(), now(), true), Decision::Approve { suspect: false });