Merge costs and margin review: gateway query, own spend, discount meters, superseded rises
14 files+558−1070/14 viewed
| 6 | 6 | import { | |
| 7 | 7 | daySeries, | |
| 8 | 8 | daysBetween, | |
| 9 | + | marginOnPrice, | |
| 9 | 10 | marginPercent, | |
| 10 | 11 | marginTone, | |
| 11 | 12 | parseBucket, | |
| ⋯ | |||
| 13 | 14 | parseMapping, | |
| 14 | 15 | parseRange, | |
| 15 | 16 | percentLabel, | |
| 17 | + | proposalOutcome, | |
| 16 | 18 | spendBanner, | |
| 17 | 19 | spendRows, | |
| 20 | + | subscriptionsOver, | |
| 18 | 21 | unitDollars, | |
| 22 | + | versionCells, | |
| 23 | + | whoPaid, | |
| 19 | 24 | } from "./costs.ts"; | |
| 20 | 25 | ||
| 21 | 26 | const day = (d: string, bucket: string, cf: number, own: number, value: number, cash: number): CostDay => ({ | |
| ⋯ | |||
| 150 | 155 | assert.deepEqual(rows.map((r) => r.key), ["comped", "trial", "free", "fixed"]); | |
| 151 | 156 | assert.equal(totalMicros, 76_000_000); | |
| 152 | 157 | }); | |
| 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 | + | }); | |
| 44 | 44 | return [...totals.values()]; | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 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 | + | ||
| 47 | 99 | /** Margin as a whole percent of revenue; none when there was none. */ | |
| 48 | 100 | export function marginPercent(revenueMicros: number, costMicros: number): number | null { | |
| 49 | 101 | return revenueMicros > 0 ? ((revenueMicros - costMicros) / revenueMicros) * 100 : null; | |
| ⋯ | |||
| 192 | 244 | /** What g1t paid this month, by bucket, with the free tier and Cloudflare's subscriptions; and the total. */ | |
| 193 | 245 | export function spendRows(caps: SpendCaps): { rows: { key: string; title: string; micros: number; note: string }[]; totalMicros: number } { | |
| 194 | 246 | 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", | |
| 200 | 252 | }; | |
| 201 | 253 | 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" }); | |
| 203 | 255 | rows.push({ | |
| 204 | 256 | key: "fixed", | |
| 205 | 257 | title: "Cloudflare subscriptions", | |
| 206 | 258 | 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)", | |
| 208 | 260 | }); | |
| 209 | 261 | return { rows, totalMicros: rows.reduce((sum, row) => sum + row.micros, 0) }; | |
| 210 | 262 | } | |
| 5 | 5 | import type { Route } from "./+types/costs-bill"; | |
| 6 | 6 | import { CostsHeader } from "~/components/costs-header"; | |
| 7 | 7 | 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"; | |
| 9 | 9 | import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server"; | |
| 10 | 10 | import { dollarsField, usd } from "~/lib/money"; | |
| 11 | 11 | ||
| ⋯ | |||
| 111 | 111 | </tr> | |
| 112 | 112 | </thead> | |
| 113 | 113 | <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 | + | })} | |
| 134 | 138 | </tbody> | |
| 135 | 139 | </table> | |
| 136 | 140 | </div> | |
| ⋯ | |||
| 278 | 282 | ||
| 279 | 283 | function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) { | |
| 280 | 284 | const rise = p.proposedCostMicros > p.currentCostMicros; | |
| 285 | + | const outcome = proposalOutcome(p); | |
| 281 | 286 | const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint"; | |
| 282 | 287 | return ( | |
| 283 | 288 | <li className="rounded-md border border-line px-4 py-3"> | |
| ⋯ | |||
| 297 | 302 | <p className="mt-1 text-xs text-muted">{p.reason}</p> | |
| 298 | 303 | <p className="mt-1 text-xs text-faint"> | |
| 299 | 304 | 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" ? ( | |
| 302 | 307 | <> | |
| 303 | 308 | {" "} | |
| 304 | − | · in force from <When at={p.effectiveAt} /> | |
| 309 | + | · in force from <When at={p.effectiveAt} time /> | |
| 305 | 310 | </> | |
| 306 | 311 | ) : null} | |
| 307 | 312 | {p.note ? ` · “${p.note}”` : ""} | |
| 7 | 7 | import { DaysChart } from "~/components/costs"; | |
| 8 | 8 | import { CostsHeader, chip, costsHref } from "~/components/costs-header"; | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server"; | |
| 12 | 12 | import { usd } from "~/lib/money"; | |
| 13 | 13 | ||
| 14 | 14 | export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | 15 | ||
| 16 | + | /** Billing's MARGIN_PERCENT: what is added to cost. */ | |
| 17 | + | const MARKUP_PERCENT = 20; | |
| 18 | + | ||
| 16 | 19 | export const loader = ({ request, context }: Route.LoaderArgs) => costsLoader(request, context); | |
| 17 | 20 | export const action = ({ request, context }: Route.ActionArgs) => costsAction(request, context); | |
| 18 | 21 | ||
| ⋯ | |||
| 23 | 26 | export default function Costs({ loaderData, actionData }: Route.ComponentProps) { | |
| 24 | 27 | const { range, report, error, done } = loaderData; | |
| 25 | 28 | 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.`; | |
| 28 | 30 | if (!report) { | |
| 29 | 31 | return ( | |
| 30 | 32 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| ⋯ | |||
| 46 | 48 | ||
| 47 | 49 | <Statement report={report} floor={floor} range={range} proposals={open.length} /> | |
| 48 | 50 | ||
| 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} /> | |
| 50 | 52 | ||
| 51 | 53 | <Section | |
| 52 | 54 | className="mt-6" | |
| ⋯ | |||
| 75 | 77 | /> | |
| 76 | 78 | </Section> | |
| 77 | 79 | ||
| 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 | + | > | |
| 79 | 85 | <div className="-mx-4 overflow-x-auto sm:-mx-5"> | |
| 80 | 86 | <table className="w-full min-w-[44rem] text-sm"> | |
| 81 | 87 | <thead> | |
| ⋯ | |||
| 85 | 91 | <th className="px-4 py-2 text-right font-medium">Cost</th> | |
| 86 | 92 | <th className="px-4 py-2 text-right font-medium">Price book said</th> | |
| 87 | 93 | <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> | |
| 89 | 95 | </tr> | |
| 90 | 96 | </thead> | |
| 91 | 97 | <tbody> | |
| ⋯ | |||
| 142 | 148 | <tbody> | |
| 143 | 149 | {report.topWorkspaces.map((w) => { | |
| 144 | 150 | 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; | |
| 145 | 154 | return ( | |
| 146 | 155 | <tr key={w.workspace} className="border-b border-line last:border-0"> | |
| 147 | 156 | <td className="px-4 py-2.5 sm:px-5"> | |
| ⋯ | |||
| 153 | 162 | <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td> | |
| 154 | 163 | <td className="tabular px-4 py-2.5 text-right text-fg-soft">{usd(w.givenMicros ?? 0)}</td> | |
| 155 | 164 | <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> | |
| 157 | 166 | </tr> | |
| 158 | 167 | ); | |
| 159 | 168 | })} | |
| ⋯ | |||
| 204 | 213 | const usagePercent = soldSomething && o.usageMarginPercent !== undefined ? o.usageMarginPercent : null; | |
| 205 | 214 | const running = o.runningCostMicros ?? 0; | |
| 206 | 215 | 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); | |
| 209 | 219 | const moneyIn = o.usageMicros + o.plansMicros; | |
| 210 | − | const spent = o.costMicros + subscriptions; | |
| 220 | + | const spent = paid.totalMicros; | |
| 211 | 221 | const net = moneyIn - spent; | |
| 212 | 222 | const givenParts = [ | |
| 213 | 223 | ["100% discounts", o.givenCompedMicros ?? 0], | |
| ⋯ | |||
| 272 | 282 | <Stat | |
| 273 | 283 | label="Margin on usage sold" | |
| 274 | 284 | 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 | + | } | |
| 276 | 290 | tone={marginTone(usagePercent, floor)} | |
| 277 | 291 | /> | |
| 278 | 292 | <Stat | |
| ⋯ | |||
| 282 | 296 | /> | |
| 283 | 297 | <Stat | |
| 284 | 298 | 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`} | |
| 287 | 301 | /> | |
| 288 | 302 | <Stat | |
| 289 | 303 | label="Proposals waiting" | |
| ⋯ | |||
| 370 | 384 | ); | |
| 371 | 385 | } | |
| 372 | 386 | ||
| 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 }) { | |
| 374 | 388 | const { rows, totalMicros } = spendRows(caps); | |
| 375 | 389 | const net = caps.revenueMicros - totalMicros; | |
| 376 | 390 | return ( | |
| ⋯ | |||
| 378 | 392 | className="mt-6" | |
| 379 | 393 | id="spend" | |
| 380 | 394 | 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." | |
| 382 | 396 | > | |
| 383 | 397 | <div className="grid gap-3 lg:grid-cols-2"> | |
| 384 | 398 | <CapMeter | |
| ⋯ | |||
| 462 | 476 | <tr className="border-b border-line"> | |
| 463 | 477 | <td className="px-4 py-2.5 sm:px-5"> | |
| 464 | 478 | 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> | |
| 466 | 480 | </td> | |
| 467 | 481 | <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(caps.revenueMicros)}</td> | |
| 468 | 482 | </tr> | |
| ⋯ | |||
| 473 | 487 | </tbody> | |
| 474 | 488 | </table> | |
| 475 | 489 | </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's bill as the | |
| 492 | + | cost of what Cloudflare runs, so sandboxes and builds inside Cloudflare's included usage cost nothing there, and here what the price book says they | |
| 493 | + | cost. Here are Cloudflare'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's spend, but the statement has it only where the reset kept | |
| 499 | + | it, as given away. | |
| 500 | + | </> | |
| 501 | + | )} | |
| 502 | + | </p> | |
| 476 | 503 | </Section> | |
| 477 | 504 | ); | |
| 478 | 505 | } | |
| 3419 | 3419 | pub created_by: String, | |
| 3420 | 3420 | /// When the price book took it on; absent while it waits for its date. | |
| 3421 | 3421 | 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, | |
| 3422 | 3429 | } | |
| 3423 | 3430 | ||
| 3424 | 3431 | /// What a workspace cost g1t over the range, Cloudflare's costs shared | |
| ⋯ | |||
| 3590 | 3597 | pub fixed_items: Vec<FixedCost>, | |
| 3591 | 3598 | /// Money in this month, through the last reconciled day. | |
| 3592 | 3599 | 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>, | |
| 3593 | 3609 | } | |
| 3594 | 3610 | ||
| 3595 | 3611 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 21 | 21 | | --- | --- | --- | | |
| 22 | 22 | | 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` | | |
| 23 | 23 | | 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. | | |
| 25 | 25 | | 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 | | |
| 26 | 26 | | `pending_usage` | Month-end meters (git, storage, scans, embeddings, the cache) as they stand. | snapshotted daily into `pending_days` | | |
| 27 | 27 | | `plan_payments` | The plan's $20. | read | | |
| ⋯ | |||
| 40 | 40 | | Secret on g1t-billing | Permissions | Used for | | |
| 41 | 41 | | --- | --- | --- | | |
| 42 | 42 | | `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 | | |
| 44 | 44 | ||
| 45 | 45 | With neither, the daily run reconciles only what g1t counted itself, and | |
| 46 | 46 | the page says the bill cannot be read. Nothing fails. To set the scoped one: | |
| ⋯ | |||
| 98 | 98 | | `embeddings` | Workers AI, Vectorize | `context` | | |
| 99 | 99 | | `security` | (Workers CPU, under `platform`) | `security` | | |
| 100 | 100 | | `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 | | |
| 102 | 102 | | `platform` | Workers, D1, KV, Queues, Email, Browser Rendering, other Durable Objects | the plan's price | | |
| 103 | 103 | ||
| 104 | 104 | For each day and bucket: | |
| ⋯ | |||
| 152 | 152 | each why by name, testing resets included (`givenResetMicros`). | |
| 153 | 153 | - **Month-end meters**: a day's figure is that day's `pending_days` | |
| 154 | 154 | 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". | |
| 157 | 160 | - **Sudo's statement** keeps apart: | |
| 158 | 161 | - **Usage sold**: cash for usage against the cost of the usage buckets | |
| 159 | 162 | less what was given. Its margin is the headline; at cost plus 20% it | |
| ⋯ | |||
| 167 | 170 | - **Given away**: by why. A budget, watched under g1t's own spend, never | |
| 168 | 171 | shown as a loss. | |
| 169 | 172 | - **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. | |
| 172 | 176 | ||
| 173 | 177 | The overall alert is (Σ cash − (Σ cost − Σ given)) / Σ cash. | |
| 174 | 178 | - **Quantities**: where a mapping names an `own_meter`, Cloudflare's | |
| ⋯ | |||
| 190 | 194 | | --- | --- | --- | | |
| 191 | 195 | | 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). | | |
| 192 | 196 | | 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. | | |
| 194 | 198 | | 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. | | |
| 195 | 199 | | 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`). | | |
| 196 | 200 | ||
| ⋯ | |||
| 218 | 222 | once per rise (`price_notices`), and the pricing page lists it with | |
| 219 | 223 | "takes effect". Rises are never retroactive; margin protection is for | |
| 220 | 224 | 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. | |
| 221 | 231 | - 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. | |
| 222 | 238 | ||
| 223 | 239 | ## Changing a mapping | |
| 224 | 240 | ||
| ⋯ | |||
| 798 | 814 | | `unpaid` | Charged, but with no real money behind it: Stripe's test key, or `FREE_WHILE_BUILDING` | | |
| 799 | 815 | ||
| 800 | 816 | 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 | |
| 802 | 819 | model provider costs g1t nothing and is not counted. Month-end meters | |
| 803 | 820 | (git, storage, scans, embeddings, the cache) are not counted here; the | |
| 804 | 821 | daily reconciliation covers them. Migration `0024_spend_caps.sql` | |
| 805 | 822 | backfills the current month from the ledger. | |
| 806 | 823 | ||
| 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 | + | ||
| 807 | 836 | ### Caps | |
| 808 | 837 | ||
| 809 | 838 | | Cap | Variable (g1t-billing) | Default | At the cap | | |
| 1593 | 1593 | reason: string; | |
| 1594 | 1594 | createdBy: string; | |
| 1595 | 1595 | 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" | ""; | |
| 1596 | 1603 | }; | |
| 1597 | 1604 | ||
| 1598 | 1605 | /** What `resetBilling` removed. */ | |
| ⋯ | |||
| 1698 | 1705 | fixedItems?: { name: string; monthlyMicros: number }[]; | |
| 1699 | 1706 | /** Money in this month, through the last reconciled day. */ | |
| 1700 | 1707 | 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[]; | |
| 1701 | 1715 | }; | |
| 1702 | 1716 | ||
| 1703 | 1717 | /** A comped account's monthly budget, at cost. */ | |
| 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); |
| 503 | 503 | .await? | |
| 504 | 504 | .and_then(|s| s.micros) | |
| 505 | 505 | .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(); | |
| 506 | 521 | let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?; | |
| 507 | 522 | Ok(SpendCaps { | |
| 523 | + | reset_micros, | |
| 524 | + | reset_workspaces, | |
| 508 | 525 | day, | |
| 509 | 526 | month, | |
| 510 | 527 | today_micros, | |
| ⋯ | |||
| 550 | 567 | } | |
| 551 | 568 | } | |
| 552 | 569 | ||
| 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 | + | ||
| 553 | 576 | /// How sudo names a bucket of g1t's own spend. | |
| 554 | 577 | pub(crate) fn bucket_title(bucket: &str) -> &'static str { | |
| 555 | 578 | match bucket { | |
| ⋯ | |||
| 675 | 698 | } | |
| 676 | 699 | ||
| 677 | 700 | #[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] | |
| 678 | 709 | fn amounts_read_in_cents() { | |
| 679 | 710 | assert_eq!(cents(150_000_000), "$150.00"); | |
| 680 | 711 | assert_eq!(cents(1_234_567), "$1.23"); | |
| 254 | 254 | ||
| 255 | 255 | /// What AI Gateway priced each day's requests at, per provider and model, | |
| 256 | 256 | /// 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. | |
| 260 | 267 | pub(crate) const GATEWAY_QUERY: &str = "query ($account: String!, $gateway: String!, $since: Date!, $until: Date!) { | |
| 261 | 268 | 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 }) { | |
| 263 | 270 | count | |
| 264 | 271 | sum { cost tokensIn tokensOut cacheReadTokens cacheWriteTokens } | |
| 265 | − | dimensions { date provider model wholesale } | |
| 272 | + | dimensions { date provider model } | |
| 266 | 273 | } | |
| 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 | + | } | |
| 267 | 279 | } } | |
| 268 | 280 | }"; | |
| 269 | 281 | ||
| ⋯ | |||
| 279 | 291 | if let Some(errors) = body["errors"].as_array().filter(|e| !e.is_empty()) { | |
| 280 | 292 | return Err(format!("AI Gateway analytics failed: {}", Value::Array(errors.clone()))); | |
| 281 | 293 | } | |
| 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(); | |
| 283 | 296 | 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))) { | |
| 285 | 298 | let d = &g["dimensions"]; | |
| 286 | 299 | let Some(day) = d["date"].as_str().filter(|day| day.len() >= 10) else { continue }; | |
| 287 | 300 | let provider = d["provider"].as_str().unwrap_or("unknown"); | |
| 288 | 301 | let model = d["model"].as_str().unwrap_or("unknown"); | |
| 289 | − | let wholesale = d["wholesale"].as_u64().unwrap_or(0) == 1; | |
| 290 | 302 | let name = format!("{}{}", if wholesale { GATEWAY_WHOLESALE } else { "" }, slug(&format!("{provider} {model}"))); | |
| 291 | 303 | let sum = |key: &str| g["sum"][key].as_f64().unwrap_or(0.0); | |
| 292 | 304 | let line = |meter: String, unit: &str, quantity: f64, cost_usd: f64| CostLine { | |
| ⋯ | |||
| 323 | 335 | pub wholesale_usd: f64, | |
| 324 | 336 | /// Runs settled with the gateway's figure short (see `keeper::settled_cost`). | |
| 325 | 337 | pub short_runs: u32, | |
| 338 | + | /// Requests the gateway logged, priced or not. | |
| 339 | + | pub requests: f64, | |
| 326 | 340 | } | |
| 327 | 341 | ||
| 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 | + | ||
| 328 | 361 | /// The caveats in AI Gateway's lines (any days, any order). | |
| 329 | 362 | pub(crate) fn gateway_caveats(lines: &[(String, f64, f64)]) -> GatewayCaveats { | |
| 330 | 363 | let mut out = GatewayCaveats::default(); | |
| ⋯ | |||
| 338 | 371 | } else if meter.ends_with(GATEWAY_CACHE_WRITE) { | |
| 339 | 372 | out.cache_write_tokens += quantity; | |
| 340 | 373 | } else { | |
| 374 | + | out.requests += quantity; | |
| 341 | 375 | *cost.entry(meter.as_str()).or_default() += cost_usd; | |
| 342 | 376 | if meter.starts_with(GATEWAY_WHOLESALE) { | |
| 343 | 377 | out.wholesale_usd += cost_usd; | |
| ⋯ | |||
| 543 | 577 | impl Billing { | |
| 544 | 578 | /// Reads Cloudflare's bill for the days due (see `window`) into | |
| 545 | 579 | /// `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)>> { | |
| 548 | 588 | if !keeper.can_read_bill() { | |
| 549 | 589 | return Ok(None); | |
| 550 | 590 | } | |
| ⋯ | |||
| 579 | 619 | } | |
| 580 | 620 | // What AI Gateway priced g1t's own provider traffic at, each day: | |
| 581 | 621 | // 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. | |
| 582 | 624 | 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()); | |
| 583 | 633 | match keeper | |
| 584 | − | .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &since, &until)) | |
| 634 | + | .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &from, &to)) | |
| 585 | 635 | .await | |
| 586 | 636 | .map_err(|e| e.to_string()) | |
| 587 | 637 | .and_then(|body| lines_from_gateway(&body)) | |
| ⋯ | |||
| 591 | 641 | // re-read day must not keep its old line. | |
| 592 | 642 | self.db | |
| 593 | 643 | .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()])? | |
| 595 | 645 | .run() | |
| 596 | 646 | .await?; | |
| 647 | + | *gateway = if lines.is_empty() { GatewayRead::Empty { visible: keeper.gateway_visible().await } } else { GatewayRead::Rows }; | |
| 597 | 648 | written += self.upsert_lines(&lines, &fetched_at).await?; | |
| 598 | 649 | } | |
| 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 | + | } | |
| 600 | 654 | } | |
| 601 | 655 | } | |
| 602 | 656 | Ok(Some((since, until, written))) | |
| ⋯ | |||
| 784 | 838 | /// AI Gateway's analytics in the shape of the schema | |
| 785 | 839 | /// (`aiGatewayRequestsAdaptiveGroups`: `count`, `sum`, `dimensions`). | |
| 786 | 840 | 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)| { | |
| 788 | 842 | json!({ | |
| 789 | 843 | "count": count, | |
| 790 | 844 | "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 } | |
| 792 | 846 | }) | |
| 793 | 847 | }; | |
| 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 }) | |
| 800 | 856 | } | |
| 801 | 857 | ||
| 802 | 858 | #[test] | |
| 803 | 859 | fn the_gateway_query_names_the_fields_its_schema_has() { | |
| 804 | 860 | // As checked against Cloudflare's GraphQL schema (introspection of | |
| 805 | 861 | // 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"] { | |
| 807 | 863 | assert!(GATEWAY_QUERY.contains(field), "{field}"); | |
| 808 | 864 | } | |
| 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 | + | } | |
| 809 | 871 | let body = gateway_variables("acct", "g1t", "2026-10-01", "2026-10-07"); | |
| 810 | 872 | assert_eq!(body["variables"]["gateway"], "g1t"); | |
| 811 | 873 | } | |
| ⋯ | |||
| 835 | 897 | assert_eq!(caveats.unpriced, vec!["anthropic_claude_new_1".to_owned()]); | |
| 836 | 898 | assert_eq!((caveats.cache_read_tokens, caveats.cache_write_tokens), (3_000_000.0, 200_000.0)); | |
| 837 | 899 | 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); | |
| 838 | 902 | // A model priced on one day and not another is priced. | |
| 839 | 903 | let mixed = vec![ | |
| 840 | 904 | ("m".to_owned(), 3.0, 0.5), | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | 285 | /// 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`). | |
| 287 | 288 | pub(crate) fn usage_key(task: Option<&str>, reference: &str) -> String { | |
| 288 | 289 | match task { | |
| 289 | 290 | Some("deployments") if reference.starts_with("deploy/") => "builds".to_owned(), | |
| 291 | + | Some("plan") => crate::margin::PLANNING_KEY.to_owned(), | |
| 290 | 292 | Some(task) => task.to_owned(), | |
| 291 | 293 | None => "other".to_owned(), | |
| 292 | 294 | } | |
| ⋯ | |||
| 1140 | 1142 | assert_eq!(usage_key(Some("deployments"), "deploy/abc"), "builds"); | |
| 1141 | 1143 | assert_eq!(usage_key(Some("deployments"), "requests/2026-10"), "deployments"); | |
| 1142 | 1144 | 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"); | |
| 1143 | 1147 | assert_eq!(usage_key(None, "crd_a"), "other"); | |
| 1144 | 1148 | } | |
| 1145 | 1149 | ||
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | /// 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. | |
| 123 | 124 | pub(crate) async fn gateway_graphql(&self, body: Value) -> Result<Value> { | |
| 124 | 125 | let Some(token) = &self.token else { | |
| 125 | 126 | return self.graphql(body).await; | |
| ⋯ | |||
| 133 | 134 | } | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 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 | + | ||
| 136 | 149 | /// What AI Gateway priced a session's requests at, and how many there | |
| 137 | 150 | /// were, with the requests it had no price for. | |
| 138 | 151 | async fn session_cost(&self, session: &str) -> Result<SessionCost> { | |
| ⋯ | |||
| 200 | 213 | } | |
| 201 | 214 | } | |
| 202 | 215 | ||
| 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 | + | ||
| 203 | 223 | /// A request to Cloudflare's API with a bearer token; anything but 200 is | |
| 204 | 224 | /// an error with what Cloudflare said. | |
| 205 | 225 | async fn send_with(token: &str, method: Method, url: &str, body: Option<Value>) -> Result<Value> { | |
| 44 | 44 | pub(crate) const OVERHEAD: [&str; 1] = ["platform"]; | |
| 45 | 45 | /// Buckets Cloudflare does not bill: their cost is g1t's own figure. | |
| 46 | 46 | 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"; | |
| 47 | 53 | /// The days drift is judged over. | |
| 48 | 54 | const DRIFT_DAYS: u64 = 7; | |
| 49 | 55 | /// The days a workspace's cost is set against its revenue. | |
| ⋯ | |||
| 432 | 438 | out | |
| 433 | 439 | } | |
| 434 | 440 | ||
| 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 | + | ||
| 435 | 452 | /// Margin as a share of what was charged, in percent; None when nothing was. | |
| 436 | 453 | pub(crate) fn margin_percent(revenue_micros: i64, cost_micros: i64) -> Option<f64> { | |
| 437 | 454 | (revenue_micros > 0).then(|| (revenue_micros - cost_micros) as f64 * 100.0 / revenue_micros as f64) | |
| ⋯ | |||
| 676 | 693 | ||
| 677 | 694 | /// The models drift's detail: the gateway's total against the ledger's, | |
| 678 | 695 | /// 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 { | |
| 680 | 697 | 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.", | |
| 683 | 700 | dollars(drift.ours as i64) | |
| 684 | 701 | ); | |
| 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}"); | |
| 685 | 718 | } | |
| 686 | 719 | let lower = drift.ours < drift.cloudflare; | |
| 687 | 720 | let wiped = resets.iter().any(|r| !r.recorded); | |
| ⋯ | |||
| 1016 | 1049 | /// day has come, and raise or clear alerts. | |
| 1017 | 1050 | pub(crate) async fn costs_daily(&self, env: &Env, keeper: &crate::keeper::Keeper) -> Result<CostsRun> { | |
| 1018 | 1051 | 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? { | |
| 1020 | 1054 | Some((since, until, lines)) => { | |
| 1021 | 1055 | run.lines = lines; | |
| 1022 | 1056 | (since, until) | |
| ⋯ | |||
| 1048 | 1082 | let window = day_before(&until, costs::BACKFILL_DAYS - 1); | |
| 1049 | 1083 | let reconcile_from = if window < since { window } else { since.clone() }; | |
| 1050 | 1084 | 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?; | |
| 1052 | 1086 | run.proposals = self.measure_units(&until).await?; | |
| 1053 | 1087 | self.apply_due_versions().await?; | |
| 1054 | 1088 | run.alerts = self.raise_alerts(env, &until, &drift).await?; | |
| ⋯ | |||
| 1102 | 1136 | .db | |
| 1103 | 1137 | .prepare(format!( | |
| 1104 | 1138 | "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, | |
| 1106 | 1141 | CASE WHEN workspace IN ({internal}) THEN 1 ELSE 0 END AS internal, | |
| 1107 | 1142 | CASE WHEN billed_to = 'workspace' THEN 1 ELSE 0 END AS own_provider, | |
| 1108 | 1143 | -SUM(amount_micros) AS cash, | |
| ⋯ | |||
| 1116 | 1151 | WHERE kind = 'usage' AND created_at >= ?1 AND created_at <= ?2 AND COALESCE(task, '') NOT IN ({charged_here}) | |
| 1117 | 1152 | AND (?3 = '' OR workspace = ?3) | |
| 1118 | 1153 | GROUP BY 1, 2, 3, 4, 5", | |
| 1119 | − | internal = crate::sales::INTERNAL_SQL | |
| 1154 | + | internal = crate::sales::INTERNAL_SQL, | |
| 1155 | + | planning = PLANNING_KEY | |
| 1120 | 1156 | )) | |
| 1121 | 1157 | .bind(&[since.into(), end.as_str().into(), only_sql.into()])? | |
| 1122 | 1158 | .all() | |
| ⋯ | |||
| 1177 | 1213 | .filter(|s| crate::storage::CHARGED_HERE.contains(&s.source.as_str()) || s.source == "domains") | |
| 1178 | 1214 | .map(|s| (s.day, s.workspace, s.source, s.cost_micros, s.charge_micros)) | |
| 1179 | 1215 | .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 | + | ); | |
| 1186 | 1222 | // The plan's price, spread over the 30 days it pays for, so a month's | |
| 1187 | 1223 | // payment does not read as one very good day and 29 bad ones. | |
| 1188 | 1224 | #[derive(Deserialize)] | |
| ⋯ | |||
| 1450 | 1486 | ||
| 1451 | 1487 | /// Drift over the last week, written to `cost_drift` (replacing the | |
| 1452 | 1488 | /// 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)>> { | |
| 1454 | 1490 | let since = day_before(until, DRIFT_DAYS - 1); | |
| 1455 | 1491 | let settings = self.cost_settings().await?; | |
| 1456 | 1492 | let rules = self.rules().await?; | |
| ⋯ | |||
| 1476 | 1512 | } | |
| 1477 | 1513 | let title = costs::bucket_title(bucket); | |
| 1478 | 1514 | 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), | |
| 1480 | 1516 | DriftKind::Count => format!( | |
| 1481 | 1517 | "{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).", | |
| 1482 | 1518 | crate::features::thousands(drift.ours.max(0.0).round() as u64), | |
| ⋯ | |||
| 2246 | 2282 | } | |
| 2247 | 2283 | ||
| 2248 | 2284 | #[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] | |
| 2249 | 2313 | fn artifacts_events_count_when_the_bill_does_not() { | |
| 2250 | 2314 | let lines = vec![ | |
| 2251 | 2315 | line("2026-10-05", SOURCE_ARTIFACTS, "artifacts", "events_pull", 120.0, 0.0), | |
| ⋯ | |||
| 2477 | 2541 | // rows), so it is said. Under the minimum, or no model cost: nothing. | |
| 2478 | 2542 | let silent = drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000); | |
| 2479 | 2543 | 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}"); | |
| 2482 | 2559 | assert!(drifts("models", &[day("models", 0, 50_000, 60_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2483 | 2560 | assert!(drifts("models", &[day("models", 0, 0, 0, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2484 | 2561 | // The detail says which way and why it may be off. | |
| 2485 | 2562 | 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()); | |
| 2487 | 2564 | assert!(detail.contains("$5.00") && detail.contains("$3.00") && detail.contains("were not charged"), "{detail}"); | |
| 2488 | 2565 | assert!(detail.contains("3,000,000 prompt-cache read") && detail.contains("no price for anthropic_claude_new_1"), "{detail}"); | |
| 2489 | 2566 | } | |
| ⋯ | |||
| 2623 | 2700 | ] | |
| 2624 | 2701 | ); | |
| 2625 | 2702 | 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(), ¬es); | |
| 2703 | + | let detail = models_detail(&drift, &costs::GatewayCaveats::default(), ¬es, &costs::GatewayRead::default()); | |
| 2627 | 2704 | assert!(detail.contains("includes model usage wiped by a testing reset of syntaqx on 2026-10-07"), "{detail}"); | |
| 2628 | 2705 | assert!(detail.contains("not a leak") && detail.contains("leaves the 7 days on 2026-10-14"), "{detail}"); | |
| 2629 | 2706 | assert!(!detail.contains("a gap that stays is a leak"), "{detail}"); | |
| ⋯ | |||
| 2634 | 2711 | assert!(!wiped_not_leaked(&leak, ¬es[1..])); | |
| 2635 | 2712 | assert!(!wiped_not_leaked(&Drift { bucket: "actions_cache".into(), ..leak }, ¬es)); | |
| 2636 | 2713 | // 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")); | |
| 2638 | 2715 | } | |
| 2639 | 2716 | } | |
| 62 | 62 | /// percent or two at a time, and an email for each would be noise. | |
| 63 | 63 | const EMAIL_RISE_PERCENT: f64 = 5.0; | |
| 64 | 64 | ||
| 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 | + | ||
| 65 | 86 | /// Owners told of rises per run, at most; the rest on the next run. | |
| 66 | 87 | const NOTICES_PER_RUN: usize = 40; | |
| 67 | 88 | ||
| ⋯ | |||
| 403 | 424 | + 1; | |
| 404 | 425 | let id = format!("pv_{meter}_{next}"); | |
| 405 | 426 | 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. | |
| 407 | 430 | 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 | + | ]) | |
| 411 | 435 | .await?; | |
| 412 | 436 | self.db | |
| 413 | 437 | .prepare( | |
| ⋯ | |||
| 524 | 548 | .into_iter() | |
| 525 | 549 | .map(|v| PriceVersion { | |
| 526 | 550 | price_micros: Price::price_for(v.cost_micros, v.markup_percent), | |
| 551 | + | basis: basis_of(&v.meter).to_owned(), | |
| 527 | 552 | id: v.id, | |
| 528 | 553 | meter: v.meter, | |
| 529 | 554 | version: v.version, | |
| ⋯ | |||
| 757 | 782 | } | |
| 758 | 783 | ||
| 759 | 784 | #[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] | |
| 760 | 811 | fn past_the_guardrail_or_with_auto_off_staff_decide() { | |
| 761 | 812 | // Up 50%: staff. | |
| 762 | 813 | assert_eq!(decide(150_000.0, 225_000.0, guard(), now(), true), Decision::Approve { suspect: false }); | |