Prices keep themselves current with what g1t pays
g1t passes its costs through, so billing now keeps a price book: each metered unit's cost to g1t and its markup, with the price always cost times markup. Sandbox time and Deployments charge from it. A keeper on billing's cron keeps the costs true. Every 15 minutes it settles finished model runs to what AI Gateway priced their requests at (each request now carries its session in the gateway's metadata), with a correction on the statement, and charges runs whose sandbox died without reporting. Daily it reads what Cloudflare billed the account and moves container and Workers for Platforms costs when they drift, recording each change; a measurement over 4x off is logged, not adopted. App traffic past the plan now counts toward a workspace's limit as it happens, not when the month closes. g1t.sh/pricing shows the live book and every change; the billing guide says how prices are set.
| 87 | 87 | assigned the issue. That is why only members of a workspace can put g1t | |
| 88 | 88 | agents to work on its repositories. | |
| 89 | 89 | ||
| 90 | + | ## How prices are set | |
| 91 | + | ||
| 92 | + | g1t passes its own costs through. Everything a workspace uses costs g1t | |
| 93 | + | money first, at Cloudflare or a model provider, and is charged at that | |
| 94 | + | cost plus a set markup. There is no seat price, and nothing is bundled to | |
| 95 | + | hide what it costs. The live prices are on | |
| 96 | + | [g1t.sh/pricing](https://g1t.sh/pricing), straight from the price book | |
| 97 | + | billing charges from. | |
| 98 | + | ||
| 99 | + | Prices keep themselves current as those costs move: | |
| 100 | + | ||
| 101 | + | - **Models.** Each of g1t's hosted runs goes through its Cloudflare AI | |
| 102 | + | Gateway, which prices every request at the provider's current rates. A | |
| 103 | + | run is charged when it finishes at what the sandbox reported; within | |
| 104 | + | about 15 minutes it is **settled** to the gateway's figure, and any | |
| 105 | + | difference appears on the statement as a correction, such as | |
| 106 | + | *Correction to "Work on acme/api#12": AI Gateway priced its 41 model | |
| 107 | + | requests at $0.0312, not $0.0298*. When a provider changes its prices, | |
| 108 | + | runs are charged the new ones from that day. A run whose sandbox stopped | |
| 109 | + | without reporting is charged from the gateway's logs instead of not at | |
| 110 | + | all. | |
| 111 | + | - **Cloudflare.** Every day, g1t checks what Cloudflare billed its account | |
| 112 | + | against what was used: Containers against the seconds containers ran, | |
| 113 | + | Workers for Platforms per request and per CPU millisecond. When a cost | |
| 114 | + | moves by 2% or more, the price book moves with it, since each price is | |
| 115 | + | its cost times its markup, and the change is listed on the pricing page | |
| 116 | + | with the reason. A measurement far from the current cost (more than 4× | |
| 117 | + | either way) is not adopted, only logged, so one odd day cannot reprice | |
| 118 | + | anything. | |
| 119 | + | ||
| 120 | + | | | Markup | | |
| 121 | + | | --- | --- | | |
| 122 | + | | Models | 20% | | |
| 123 | + | | Deploy builds, app requests, CPU and apps | 20% | | |
| 124 | + | | Sandbox time | 138%, which also pays for the orchestration around each sandbox and everyone's free minutes | | |
| 125 | + | ||
| 90 | 126 | ## Sandbox time | |
| 91 | 127 | ||
| 92 | 128 | Every sandbox g1t starts for a workspace runs on Cloudflare Containers, and | |
| 98 | 134 | | | | | |
| 99 | 135 | | --- | --- | | |
| 100 | 136 | | Free each month | 500 minutes (calendar month, UTC) | | |
| 101 | − | | Past that | $0.003 a minute, by the second | | |
| 137 | + | | Past that | $0.003 a minute, by the second, at today's cost | | |
| 102 | 138 | | What it costs g1t | about $0.0013 a minute (Containers, standard-1) | | |
| 103 | 139 | ||
| 140 | + | Both follow what Cloudflare bills; see [How prices are set](#how-prices-are-set). | |
| 141 | + | ||
| 104 | 142 | Deploy builds are not counted here: [Deployments](/guides/deployments/) | |
| 105 | 143 | charges them by the second on its own plan. | |
| 106 | 144 |
| 165 | 165 | </Form> | |
| 166 | 166 | <nav className="flex items-center gap-0.5"> | |
| 167 | 167 | <HeaderLink to="/explore">Explore</HeaderLink> | |
| 168 | + | <HeaderLink to="/pricing">Pricing</HeaderLink> | |
| 168 | 169 | <HeaderLink to="https://docs.g1t.sh/">Docs</HeaderLink> | |
| 169 | 170 | </nav> | |
| 170 | 171 | <div className="ml-auto flex items-center gap-2"> | |
| 262 | 263 | title: "Product", | |
| 263 | 264 | links: [ | |
| 264 | 265 | ["Explore repositories", "/explore"], | |
| 266 | + | ["Pricing", "/pricing"], | |
| 265 | 267 | ["g1t agents", "https://docs.g1t.sh/guides/g1t-agents/"], | |
| 266 | 268 | ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"], | |
| 267 | 269 | ["Integrations", "https://docs.g1t.sh/guides/integrations/"], |
| 13 | 13 | route("new", "routes/new.tsx"), | |
| 14 | 14 | route("settings", "routes/settings.tsx"), | |
| 15 | 15 | route("explore", "routes/explore.tsx", { id: "explore" }), | |
| 16 | + | route("pricing", "routes/pricing.tsx"), | |
| 16 | 17 | route("search", "routes/explore.tsx", { id: "search" }), | |
| 17 | 18 | route("workspaces/new", "routes/workspace/new.tsx"), | |
| 18 | 19 | // A workspace's own pages sit under `-`, which no repository can be named. |
| 1 | + | import { ArrowUpRight } from "lucide-react"; | |
| 2 | + | ||
| 3 | + | import { DEPLOYMENTS_ALLOWANCE, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/pricing"; | |
| 6 | + | import { TimeAgo } from "../components/ui"; | |
| 7 | + | import { billing } from "../lib/services.server"; | |
| 8 | + | ||
| 9 | + | export function meta() { | |
| 10 | + | return [ | |
| 11 | + | { title: "Pricing · g1t" }, | |
| 12 | + | { | |
| 13 | + | name: "description", | |
| 14 | + | content: "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus a set markup. No seats.", | |
| 15 | + | }, | |
| 16 | + | ]; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export async function loader() { | |
| 20 | + | const book = await billing.prices().catch(() => null); | |
| 21 | + | return { book }; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** A price in dollars, with as many digits as it needs to say anything. */ | |
| 25 | + | function money(micros: number): string { | |
| 26 | + | const dollars = micros / MICROS_PER_DOLLAR; | |
| 27 | + | if (dollars >= 1) return `$${dollars.toFixed(2)}`; | |
| 28 | + | if (dollars >= 0.01) return `$${dollars.toFixed(3)}`; | |
| 29 | + | return `$${dollars.toPrecision(2)}`; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** Per second is easier to read per minute. */ | |
| 33 | + | function perUnit(price: Price, micros: number): string { | |
| 34 | + | return price.unit === "second" ? `${money(micros * 60)} a minute` : `${money(micros)} per ${price.unit}`; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | const HOW = [ | |
| 38 | + | { | |
| 39 | + | title: "Our cost, passed through", | |
| 40 | + | body: "Every sandbox second, build, app request and model token costs g1t money at Cloudflare or a model provider. Each is metered and charged at that cost plus a set markup. Use a little, pay a little.", | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | title: "Prices follow costs, by themselves", | |
| 44 | + | body: "Model runs are charged at what Cloudflare's AI Gateway priced each request at, so a provider's price change reaches you the same day. Every day, each Cloudflare cost is checked against what Cloudflare billed g1t; when one moves, its price moves with it, and the change is listed below.", | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | title: "No seats, ever", | |
| 48 | + | body: "Add as many people and agents as you like. A workspace pays for what it uses, and for the features it turns on.", | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | title: "Limits that protect both of us", | |
| 52 | + | body: "Usage not yet paid for can only go so far: $3 for a new workspace, growing with what it pays. At the limit, work stops instead of running up a bill. Owners can set a lower one.", | |
| 53 | + | }, | |
| 54 | + | ]; | |
| 55 | + | ||
| 56 | + | export default function Pricing({ loaderData }: Route.ComponentProps) { | |
| 57 | + | const { book } = loaderData; | |
| 58 | + | const checked = book?.prices.map((p) => p.checkedAt).filter((at): at is string => !!at).sort().at(-1); | |
| 59 | + | return ( | |
| 60 | + | <main className="mx-auto max-w-4xl px-4 py-12"> | |
| 61 | + | <p className="text-sm font-medium text-accent">Pricing</p> | |
| 62 | + | <h1 className="mt-2 text-3xl font-semibold tracking-tight sm:text-4xl">What it costs us, plus a markup</h1> | |
| 63 | + | <p className="mt-3 max-w-2xl text-muted"> | |
| 64 | + | g1t runs on Cloudflare and model providers, and passes those costs through. The numbers on this page are the | |
| 65 | + | live price book g1t charges from. | |
| 66 | + | </p> | |
| 67 | + | <div className="mt-5 rounded-xl border border-accent/30 bg-accent/5 px-4 py-3 text-sm"> | |
| 68 | + | <span className="font-medium">Free while g1t is being built out.</span>{" "} | |
| 69 | + | <span className="text-muted"> | |
| 70 | + | Usage is recorded at these prices but not charged for now. Paid features, such as Deployments, are charged. | |
| 71 | + | </span> | |
| 72 | + | </div> | |
| 73 | + | ||
| 74 | + | <div className="mt-10 grid gap-4 sm:grid-cols-2"> | |
| 75 | + | {HOW.map((item) => ( | |
| 76 | + | <section key={item.title} className="rounded-xl border border-line bg-surface p-5"> | |
| 77 | + | <h2 className="font-medium">{item.title}</h2> | |
| 78 | + | <p className="mt-1.5 text-sm text-muted">{item.body}</p> | |
| 79 | + | </section> | |
| 80 | + | ))} | |
| 81 | + | </div> | |
| 82 | + | ||
| 83 | + | <h2 className="mt-14 text-xl font-semibold tracking-tight">Usage</h2> | |
| 84 | + | <p className="mt-1 text-sm text-muted"> | |
| 85 | + | {checked ? ( | |
| 86 | + | <> | |
| 87 | + | Last checked against Cloudflare's bill <TimeAgo at={checked} />. | |
| 88 | + | </> | |
| 89 | + | ) : ( | |
| 90 | + | "Starting from Cloudflare's published prices; checked against its bill daily." | |
| 91 | + | )} | |
| 92 | + | </p> | |
| 93 | + | <div className="mt-4 overflow-x-auto rounded-xl border border-line"> | |
| 94 | + | <table className="w-full min-w-[36rem] text-left text-sm"> | |
| 95 | + | <thead className="border-b border-line text-xs text-muted"> | |
| 96 | + | <tr> | |
| 97 | + | <th className="px-4 py-2.5 font-medium">What</th> | |
| 98 | + | <th className="px-4 py-2.5 font-medium">Costs g1t</th> | |
| 99 | + | <th className="px-4 py-2.5 font-medium">Markup</th> | |
| 100 | + | <th className="px-4 py-2.5 font-medium">You pay</th> | |
| 101 | + | </tr> | |
| 102 | + | </thead> | |
| 103 | + | <tbody className="divide-y divide-line"> | |
| 104 | + | <tr> | |
| 105 | + | <td className="px-4 py-3"> | |
| 106 | + | <p className="font-medium">Models</p> | |
| 107 | + | <p className="text-xs text-faint">g1t's hosted models, through AI Gateway</p> | |
| 108 | + | </td> | |
| 109 | + | <td className="px-4 py-3 text-muted">What the provider charges, per request</td> | |
| 110 | + | <td className="px-4 py-3 tabular-nums">{book?.modelMarginPercent ?? 20}%</td> | |
| 111 | + | <td className="px-4 py-3 text-muted">Cost + {book?.modelMarginPercent ?? 20}%</td> | |
| 112 | + | </tr> | |
| 113 | + | {(book?.prices ?? []).map((price) => ( | |
| 114 | + | <tr key={price.meter}> | |
| 115 | + | <td className="px-4 py-3"> | |
| 116 | + | <p className="font-medium">{price.title}</p> | |
| 117 | + | <p className="text-xs text-faint"> | |
| 118 | + | {price.source === "cloudflare" ? "Measured from Cloudflare's bill" : "Cloudflare's published price"} | |
| 119 | + | {price.meter === "sandbox_second" && " · 500 minutes free each month"} | |
| 120 | + | </p> | |
| 121 | + | </td> | |
| 122 | + | <td className="px-4 py-3 font-mono text-xs tabular-nums text-muted">{perUnit(price, price.costMicros)}</td> | |
| 123 | + | <td className="px-4 py-3 tabular-nums">{price.markupPercent}%</td> | |
| 124 | + | <td className="px-4 py-3 font-mono text-xs tabular-nums">{perUnit(price, price.priceMicros)}</td> | |
| 125 | + | </tr> | |
| 126 | + | ))} | |
| 127 | + | </tbody> | |
| 128 | + | </table> | |
| 129 | + | </div> | |
| 130 | + | <p className="mt-3 text-xs text-faint"> | |
| 131 | + | Sandbox time carries more markup than the rest: it pays for the orchestration around each sandbox, and the | |
| 132 | + | free minutes everyone gets. | |
| 133 | + | </p> | |
| 134 | + | ||
| 135 | + | <h2 className="mt-14 text-xl font-semibold tracking-tight">Features</h2> | |
| 136 | + | <p className="mt-1 text-sm text-muted">Turned on per workspace with a monthly plan. Never free.</p> | |
| 137 | + | <section className="mt-4 rounded-xl border border-line bg-surface p-5"> | |
| 138 | + | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 139 | + | <h3 className="font-medium">Deployments</h3> | |
| 140 | + | <p> | |
| 141 | + | <span className="text-2xl font-semibold">$5</span> <span className="text-sm text-muted">/ month</span> | |
| 142 | + | </p> | |
| 143 | + | </div> | |
| 144 | + | <p className="mt-1 text-sm text-muted"> | |
| 145 | + | Includes {DEPLOYMENTS_ALLOWANCE.apps} apps up at once, {(DEPLOYMENTS_ALLOWANCE.requests / 1e6).toLocaleString()}{" "} | |
| 146 | + | million requests and {(DEPLOYMENTS_ALLOWANCE.cpuMs / 1e6).toLocaleString()} million CPU milliseconds a month; | |
| 147 | + | builds, and usage past that, at the prices above. | |
| 148 | + | </p> | |
| 149 | + | </section> | |
| 150 | + | ||
| 151 | + | <h2 className="mt-14 text-xl font-semibold tracking-tight">Price changes</h2> | |
| 152 | + | {book && book.changes.length > 0 ? ( | |
| 153 | + | <ul className="mt-4 divide-y divide-line rounded-xl border border-line"> | |
| 154 | + | {book.changes.map((change) => { | |
| 155 | + | const title = book.prices.find((p) => p.meter === change.meter)?.title ?? change.meter; | |
| 156 | + | const up = change.newCostMicros > change.oldCostMicros; | |
| 157 | + | return ( | |
| 158 | + | <li key={`${change.meter}-${change.createdAt}`} className="px-4 py-3 text-sm"> | |
| 159 | + | <p> | |
| 160 | + | <span className="font-medium">{title}</span>{" "} | |
| 161 | + | <span className={up ? "text-warn" : "text-accent"}> | |
| 162 | + | {up ? "up" : "down"} {Math.abs((change.newCostMicros / change.oldCostMicros - 1) * 100).toFixed(1)}% | |
| 163 | + | </span> | |
| 164 | + | </p> | |
| 165 | + | <p className="mt-0.5 text-xs text-faint"> | |
| 166 | + | {change.reason} · <TimeAgo at={change.createdAt} /> | |
| 167 | + | </p> | |
| 168 | + | </li> | |
| 169 | + | ); | |
| 170 | + | })} | |
| 171 | + | </ul> | |
| 172 | + | ) : ( | |
| 173 | + | <p className="mt-2 text-sm text-muted">None yet. When a cost moves, it is listed here with why.</p> | |
| 174 | + | )} | |
| 175 | + | ||
| 176 | + | <a | |
| 177 | + | href="https://docs.g1t.sh/guides/usage-and-billing/" | |
| 178 | + | className="mt-12 inline-flex items-center gap-1.5 text-sm text-accent hover:underline" | |
| 179 | + | > | |
| 180 | + | How usage and billing work | |
| 181 | + | <ArrowUpRight size={14} /> | |
| 182 | + | </a> | |
| 183 | + | </main> | |
| 184 | + | ); | |
| 185 | + | } |
| 181 | 181 | /// The runner, which is TypeScript, sends it as `billedTo`. | |
| 182 | 182 | #[serde(default = "g1t", alias = "billedTo")] | |
| 183 | 183 | pub billed_to: String, | |
| 184 | + | /// The model session's id, when its requests go through g1t's AI | |
| 185 | + | /// Gateway: settling charges the run what the gateway priced them at. | |
| 186 | + | #[serde(default)] | |
| 187 | + | pub session: Option<String>, | |
| 184 | 188 | } | |
| 185 | 189 | ||
| 186 | 190 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 401 | 405 | pub workspace: String, | |
| 402 | 406 | } | |
| 403 | 407 | ||
| 408 | + | /// `note_pending`: usage this month that will be charged later, such as | |
| 409 | + | /// app traffic past a plan, so the workspace's limit counts it now. Each | |
| 410 | + | /// report replaces the last for that workspace, source and month. Called | |
| 411 | + | /// by the service that meters it. Returns `bool`. | |
| 412 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 413 | + | #[serde(rename_all = "camelCase")] | |
| 414 | + | pub struct NotePendingArgs { | |
| 415 | + | pub workspace: String, | |
| 416 | + | /// `deployments`. | |
| 417 | + | pub source: String, | |
| 418 | + | /// What it cost g1t so far this month, before the margin. | |
| 419 | + | pub cost_micros: i64, | |
| 420 | + | } | |
| 421 | + | ||
| 404 | 422 | /// `set_spend_limit`: the owner's own monthly ceiling, under g1t's; None | |
| 405 | 423 | /// removes it. Owners only. Returns `Outcome<Limit>`. | |
| 406 | 424 | #[derive(Debug, Serialize, Deserialize)] | |
| 411 | 429 | pub spend_limit_micros: Option<i64>, | |
| 412 | 430 | } | |
| 413 | 431 | ||
| 432 | + | /// One metered unit: what it costs g1t, and what it is sold at. The price | |
| 433 | + | /// is always `cost × (100 + markup) / 100`, so it follows the cost. | |
| 434 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 435 | + | #[serde(rename_all = "camelCase")] | |
| 436 | + | pub struct Price { | |
| 437 | + | /// `sandbox_second`, `build_second`, `app_requests`, `app_cpu`, `app_month`. | |
| 438 | + | pub meter: String, | |
| 439 | + | pub title: String, | |
| 440 | + | pub unit: String, | |
| 441 | + | /// Millionths of a dollar per unit; may have a fraction. | |
| 442 | + | pub cost_micros: f64, | |
| 443 | + | pub markup_percent: u32, | |
| 444 | + | pub price_micros: f64, | |
| 445 | + | /// `list`: Cloudflare's published price. `cloudflare`: what Cloudflare | |
| 446 | + | /// actually billed g1t, measured. | |
| 447 | + | pub source: String, | |
| 448 | + | /// When it was last checked against Cloudflare's bill. | |
| 449 | + | pub checked_at: Option<String>, | |
| 450 | + | pub updated_at: String, | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | impl Price { | |
| 454 | + | pub fn price_for(cost_micros: f64, markup_percent: u32) -> f64 { | |
| 455 | + | cost_micros * f64::from(100 + markup_percent) / 100.0 | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | /// A cost that moved. | |
| 460 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 461 | + | #[serde(rename_all = "camelCase")] | |
| 462 | + | pub struct PriceChange { | |
| 463 | + | pub meter: String, | |
| 464 | + | pub old_cost_micros: f64, | |
| 465 | + | pub new_cost_micros: f64, | |
| 466 | + | pub markup_percent: u32, | |
| 467 | + | pub reason: String, | |
| 468 | + | pub created_at: String, | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | /// `prices`: every metered price and the recent changes. Public. Returns | |
| 472 | + | /// `PriceBook`. | |
| 473 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 474 | + | #[serde(rename_all = "camelCase")] | |
| 475 | + | pub struct PriceBook { | |
| 476 | + | pub prices: Vec<Price>, | |
| 477 | + | pub changes: Vec<PriceChange>, | |
| 478 | + | /// The margin on model usage, which is charged at what AI Gateway | |
| 479 | + | /// priced each request at. | |
| 480 | + | pub model_margin_percent: u32, | |
| 481 | + | } | |
| 482 | + | ||
| 414 | 483 | /// What a feature's plan costs and includes. | |
| 415 | 484 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 416 | 485 | #[serde(rename_all = "camelCase")] |
| 353 | 353 | pub provider_name: Option<String>, | |
| 354 | 354 | /// The model to use instead of g1t's choice, if the connection names one. | |
| 355 | 355 | pub model: Option<String>, | |
| 356 | + | /// Names the run in AI Gateway's logs (`metadata.session`), so billing | |
| 357 | + | /// can charge each run what the gateway priced its requests at. Not a | |
| 358 | + | /// secret: it cannot be turned back into the token. | |
| 359 | + | #[serde(default)] | |
| 360 | + | pub id: String, | |
| 356 | 361 | } | |
| 357 | 362 | ||
| 358 | 363 | /// What the model proxy needs to forward one run's requests. | |
| 376 | 381 | pub repo: String, | |
| 377 | 382 | pub number: u32, | |
| 378 | 383 | pub task: String, | |
| 384 | + | /// The session's id; see `ModelSession::id`. | |
| 385 | + | #[serde(default)] | |
| 386 | + | pub session: String, | |
| 379 | 387 | /// For `endpoint`: where to send requests. | |
| 380 | 388 | pub base_url: Option<String>, | |
| 381 | 389 | /// For `anthropic` and `endpoint`: the workspace's key. |
| 93 | 93 | message: string | null; | |
| 94 | 94 | }; | |
| 95 | 95 | ||
| 96 | + | /** One metered unit: what it costs g1t and what it is sold at; the price follows the cost. */ | |
| 97 | + | export type Price = { | |
| 98 | + | meter: "sandbox_second" | "build_second" | "app_requests" | "app_cpu" | "app_month" | string; | |
| 99 | + | title: string; | |
| 100 | + | unit: string; | |
| 101 | + | costMicros: number; | |
| 102 | + | markupPercent: number; | |
| 103 | + | priceMicros: number; | |
| 104 | + | /** `list`: Cloudflare's published price. `cloudflare`: measured from Cloudflare's bill. */ | |
| 105 | + | source: "list" | "cloudflare" | string; | |
| 106 | + | checkedAt: string | null; | |
| 107 | + | updatedAt: string; | |
| 108 | + | }; | |
| 109 | + | ||
| 110 | + | export type PriceChange = { | |
| 111 | + | meter: string; | |
| 112 | + | oldCostMicros: number; | |
| 113 | + | newCostMicros: number; | |
| 114 | + | markupPercent: number; | |
| 115 | + | reason: string; | |
| 116 | + | createdAt: string; | |
| 117 | + | }; | |
| 118 | + | ||
| 119 | + | export type PriceBook = { prices: Price[]; changes: PriceChange[]; modelMarginPercent: number }; | |
| 120 | + | ||
| 96 | 121 | export type Trial = { | |
| 97 | 122 | open: boolean; | |
| 98 | 123 | usedMicros: number; | |
| 205 | 230 | repo?: string | null; | |
| 206 | 231 | reference: string; | |
| 207 | 232 | }): Promise<Result<boolean>>; | |
| 233 | + | /** | |
| 234 | + | * Usage this month to be charged later (app traffic past a plan), so the | |
| 235 | + | * workspace's limit counts it now. Replaces the last report. | |
| 236 | + | */ | |
| 237 | + | notePending(workspace: string, source: "deployments", costMicros: number): Promise<boolean>; | |
| 238 | + | /** Every metered price and the recent changes. Public. */ | |
| 239 | + | prices(): Promise<PriceBook>; | |
| 208 | 240 | /** A workspace's limit, for its members. */ | |
| 209 | 241 | limit(workspace: string, viewer: Viewer): Promise<Result<Limit>>; | |
| 210 | 242 | /** The same, for the services that enforce it. */ | |
| 231 | 263 | model: string; | |
| 232 | 264 | /** `workspace` when the run uses the workspace's own model provider. */ | |
| 233 | 265 | billedTo?: "g1t" | "workspace"; | |
| 266 | + | /** The model session's id, so the run can be settled at AI Gateway's price. */ | |
| 267 | + | session?: string | null; | |
| 234 | 268 | }): Promise<Result<RunTicket | null>>; | |
| 235 | 269 | } | |
| 236 | 270 |
| 202 | 202 | recordSandbox: (usage) => call("record_sandbox", usage), | |
| 203 | 203 | limit: (workspace, viewer) => call("limit", { workspace, viewer }), | |
| 204 | 204 | checkLimit: (workspace) => call("check_limit", { workspace }), | |
| 205 | + | prices: () => call("prices", {}), | |
| 206 | + | notePending: (workspace, source, costMicros) => call("note_pending", { workspace, source, costMicros }), | |
| 205 | 207 | setSpendLimit: (actor, workspace, spendLimitMicros) => | |
| 206 | 208 | call("set_spend_limit", { actor, workspace, spendLimitMicros }), | |
| 207 | 209 | }; |
| 122 | 122 | billedTo: "g1t" | "workspace"; | |
| 123 | 123 | providerName: string | null; | |
| 124 | 124 | model: string | null; | |
| 125 | + | /** Names the run in AI Gateway's logs (`metadata.session`), for billing. */ | |
| 126 | + | id: string; | |
| 125 | 127 | }; | |
| 126 | 128 | ||
| 127 | 129 | export type ModelUpstream = { | |
| 138 | 140 | repo: string; | |
| 139 | 141 | number: number; | |
| 140 | 142 | task: string; | |
| 143 | + | /** The session's id; see `ModelSession.id`. */ | |
| 144 | + | session: string; | |
| 141 | 145 | baseUrl: string | null; | |
| 142 | 146 | apiKey: string | null; | |
| 143 | 147 | authHeader: string | null; |
| 6 | 6 | /** Routes and reserved words that may not be registered as usernames. */ | |
| 7 | 7 | const RESERVED = new Set([ | |
| 8 | 8 | "api", "mcp", "login", "logout", "register", "new", "settings", "search", | |
| 9 | − | "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about", | |
| 9 | + | "admin", "auth", "pulls", "issues", "verify", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing", | |
| 10 | 10 | ]); | |
| 11 | 11 | ||
| 12 | 12 | export function isValidNamespace(value: string): boolean { |
| 1 | + | -- Prices that keep themselves current. See src/keeper.rs. | |
| 2 | + | ||
| 3 | + | -- Which AI Gateway session a run's model requests went through, and what | |
| 4 | + | -- the gateway priced them at once settled. A run is first charged what | |
| 5 | + | -- the sandbox reported; settling corrects it to the gateway's figure. | |
| 6 | + | ALTER TABLE runs ADD COLUMN session_id TEXT; | |
| 7 | + | ALTER TABLE runs ADD COLUMN settled_at TEXT; | |
| 8 | + | ALTER TABLE runs ADD COLUMN gateway_cost_micros INTEGER; | |
| 9 | + | ||
| 10 | + | -- What each metered unit costs g1t, and the markup it is sold at. Price | |
| 11 | + | -- is always cost × (100 + markup) / 100, so when a cost moves, the price | |
| 12 | + | -- moves with it. | |
| 13 | + | CREATE TABLE prices ( | |
| 14 | + | meter TEXT PRIMARY KEY, | |
| 15 | + | title TEXT NOT NULL, | |
| 16 | + | -- second, million requests, million CPU ms, app-month. | |
| 17 | + | unit TEXT NOT NULL, | |
| 18 | + | -- Millionths of a dollar per unit; fractions allowed. | |
| 19 | + | cost_micros REAL NOT NULL, | |
| 20 | + | markup_percent INTEGER NOT NULL, | |
| 21 | + | -- list (Cloudflare's published price) or cloudflare (what Cloudflare | |
| 22 | + | -- actually billed g1t, measured). | |
| 23 | + | source TEXT NOT NULL, | |
| 24 | + | checked_at TEXT, | |
| 25 | + | updated_at TEXT NOT NULL | |
| 26 | + | ); | |
| 27 | + | ||
| 28 | + | INSERT INTO prices (meter, title, unit, cost_micros, markup_percent, source, updated_at) VALUES | |
| 29 | + | ('sandbox_second', 'Sandbox time', 'second', 21, 138, 'list', '2026-10-04T00:00:00Z'), | |
| 30 | + | ('build_second', 'Deploy builds', 'second', 21, 20, 'list', '2026-10-04T00:00:00Z'), | |
| 31 | + | ('app_requests', 'App requests', 'million requests', 300000, 20, 'list', '2026-10-04T00:00:00Z'), | |
| 32 | + | ('app_cpu', 'App CPU time', 'million CPU ms', 20000, 20, 'list', '2026-10-04T00:00:00Z'), | |
| 33 | + | ('app_month', 'Apps up past the plan', 'app-month', 20000, 20, 'list', '2026-10-04T00:00:00Z'); | |
| 34 | + | ||
| 35 | + | -- Every time a cost moved, and why: the public record of price changes. | |
| 36 | + | CREATE TABLE price_changes ( | |
| 37 | + | id TEXT PRIMARY KEY, | |
| 38 | + | meter TEXT NOT NULL, | |
| 39 | + | old_cost_micros REAL NOT NULL, | |
| 40 | + | new_cost_micros REAL NOT NULL, | |
| 41 | + | markup_percent INTEGER NOT NULL, | |
| 42 | + | reason TEXT NOT NULL, | |
| 43 | + | created_at TEXT NOT NULL | |
| 44 | + | ); | |
| 45 | + | CREATE INDEX price_changes_by_time ON price_changes (created_at); | |
| 46 | + | ||
| 47 | + | -- What Cloudflare billed g1t's account, as its usage API reports it, kept | |
| 48 | + | -- as given so the measured costs can be checked. | |
| 49 | + | CREATE TABLE cloudflare_usage ( | |
| 50 | + | period_start TEXT NOT NULL, | |
| 51 | + | period_end TEXT NOT NULL, | |
| 52 | + | service TEXT NOT NULL, | |
| 53 | + | unit TEXT NOT NULL, | |
| 54 | + | quantity REAL NOT NULL, | |
| 55 | + | cost_usd REAL NOT NULL, | |
| 56 | + | fetched_at TEXT NOT NULL, | |
| 57 | + | PRIMARY KEY (period_start, service, unit) | |
| 58 | + | ); |
| 1 | + | -- Usage this month that is not on the ledger yet, such as app traffic | |
| 2 | + | -- past the Deployments plan, which is charged when the month closes. It | |
| 3 | + | -- counts toward the workspace's limit as it happens. Replaced on each | |
| 4 | + | -- report; see `note_pending` in src/limits.rs. | |
| 5 | + | CREATE TABLE pending_usage ( | |
| 6 | + | workspace TEXT NOT NULL, | |
| 7 | + | -- deployments. | |
| 8 | + | source TEXT NOT NULL, | |
| 9 | + | -- YYYY-MM. | |
| 10 | + | month TEXT NOT NULL, | |
| 11 | + | -- What it will be charged, in millionths of a dollar. | |
| 12 | + | charge_micros INTEGER NOT NULL, | |
| 13 | + | updated_at TEXT NOT NULL, | |
| 14 | + | PRIMARY KEY (workspace, source, month) | |
| 15 | + | ); |
| 1 | + | //! Keeps every price current with what g1t actually pays. | |
| 2 | + | //! | |
| 3 | + | //! g1t passes its own costs through, so a price is only right while the | |
| 4 | + | //! cost under it is. Two jobs keep them right, on the billing service's | |
| 5 | + | //! cron: | |
| 6 | + | //! | |
| 7 | + | //! - **Settling runs** (every 15 minutes). A model run is charged when it | |
| 8 | + | //! finishes at what the sandbox reported. Each of g1t's hosted runs goes | |
| 9 | + | //! through its AI Gateway, which prices every request at the provider's | |
| 10 | + | //! current rates and logs it with the run's session. Settling sums those | |
| 11 | + | //! logs and corrects the charge to the gateway's figure, with a | |
| 12 | + | //! correction on the statement. A run whose sandbox died before | |
| 13 | + | //! reporting is charged here instead of never. | |
| 14 | + | //! - **Checking costs** (daily). What Cloudflare billed g1t's account, from | |
| 15 | + | //! its usage API, is measured against how much was used: Containers | |
| 16 | + | //! against the seconds containers ran, Workers for Platforms per request | |
| 17 | + | //! and per CPU millisecond. When a measured cost moves, the price book | |
| 18 | + | //! moves with it, since each price is its cost plus a set markup, and | |
| 19 | + | //! the change is recorded where anyone can see it. A measurement far off | |
| 20 | + | //! the current cost is not adopted, only logged, so one odd day of data | |
| 21 | + | //! cannot reprice everything. | |
| 22 | + | ||
| 23 | + | use g1t_contracts::billing::{EntryKind, MICROS_PER_DOLLAR, Price, PriceBook, PriceChange}; | |
| 24 | + | use g1t_contracts::new_id; | |
| 25 | + | use g1t_contracts::time::rfc3339; | |
| 26 | + | use g1t_kit::now_ms; | |
| 27 | + | use serde::Deserialize; | |
| 28 | + | use serde_json::{Value, json}; | |
| 29 | + | use worker::{Env, Fetch, Headers, Method, Request, RequestInit, Result}; | |
| 30 | + | ||
| 31 | + | use crate::{Billing, RunRow, charge_micros}; | |
| 32 | + | ||
| 33 | + | /// The cron that also checks costs against Cloudflare's bill. | |
| 34 | + | pub(crate) const DAILY: &str = "17 4 * * *"; | |
| 35 | + | ||
| 36 | + | /// A run is settled once its logs have had time to land. | |
| 37 | + | const SETTLE_AFTER_MS: u64 = 5 * 60 * 1000; | |
| 38 | + | /// A run with no gateway logs after this is left as reported. | |
| 39 | + | const GIVE_UP_AFTER_MS: u64 = 3 * 60 * 60 * 1000; | |
| 40 | + | /// A run never finished after this died without reporting. | |
| 41 | + | const ABANDONED_AFTER_MS: u64 = 3 * 60 * 60 * 1000; | |
| 42 | + | /// Too little spend to measure a cost from. | |
| 43 | + | const MIN_MEASURED_USD: f64 = 5.0; | |
| 44 | + | /// Smaller moves are noise. | |
| 45 | + | const MIN_CHANGE: f64 = 0.02; | |
| 46 | + | /// A measurement outside this factor of the current cost is suspect. | |
| 47 | + | const MAX_FACTOR: f64 = 4.0; | |
| 48 | + | ||
| 49 | + | /// Where the keeper reads what g1t pays. | |
| 50 | + | pub(crate) struct Keeper { | |
| 51 | + | /// `CLOUDFLARE_USAGE_TOKEN`: Billing, Account Analytics and AI Gateway, | |
| 52 | + | /// read only. | |
| 53 | + | token: Option<String>, | |
| 54 | + | account: String, | |
| 55 | + | gateway: String, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl Keeper { | |
| 59 | + | pub(crate) fn from_env(env: &Env) -> Self { | |
| 60 | + | let var = |name: &str| env.var(name).map(|v| v.to_string()).unwrap_or_default(); | |
| 61 | + | Keeper { | |
| 62 | + | token: env.secret("CLOUDFLARE_USAGE_TOKEN").ok().map(|v| v.to_string()).filter(|v| !v.is_empty()), | |
| 63 | + | account: var("CLOUDFLARE_ACCOUNT_ID"), | |
| 64 | + | gateway: var("AI_GATEWAY_ID"), | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | async fn send(&self, method: Method, url: &str, body: Option<Value>) -> Result<Value> { | |
| 69 | + | let Some(token) = &self.token else { | |
| 70 | + | return Err(worker::Error::RustError("no CLOUDFLARE_USAGE_TOKEN".into())); | |
| 71 | + | }; | |
| 72 | + | let headers = Headers::new(); | |
| 73 | + | headers.set("authorization", &format!("Bearer {token}"))?; | |
| 74 | + | headers.set("content-type", "application/json")?; | |
| 75 | + | let mut init = RequestInit::new(); | |
| 76 | + | init.with_method(method).with_headers(headers); | |
| 77 | + | if let Some(body) = body { | |
| 78 | + | init.with_body(Some(body.to_string().into())); | |
| 79 | + | } | |
| 80 | + | let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?; | |
| 81 | + | let status = response.status_code(); | |
| 82 | + | let value: Value = response.json().await.unwrap_or(Value::Null); | |
| 83 | + | if status != 200 { | |
| 84 | + | return Err(worker::Error::RustError(format!("Cloudflare answered {status}: {value}"))); | |
| 85 | + | } | |
| 86 | + | Ok(value) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | fn api(&self, path: &str) -> String { | |
| 90 | + | format!("https://api.cloudflare.com/client/v4/accounts/{}{path}", self.account) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// What AI Gateway priced a session's requests at, in dollars, and how | |
| 94 | + | /// many there were. | |
| 95 | + | async fn session_cost(&self, session: &str) -> Result<(f64, u32)> { | |
| 96 | + | let mut cost = 0.0; | |
| 97 | + | let mut count = 0; | |
| 98 | + | for page in 1..=40 { | |
| 99 | + | let url = self.api(&format!( | |
| 100 | + | "/ai-gateway/gateways/{}/logs?per_page=50&page={page}\ | |
| 101 | + | &filters[0][key]=metadata.value&filters[0][operator]=eq&filters[0][value][0]={session}", | |
| 102 | + | self.gateway | |
| 103 | + | )); | |
| 104 | + | let body = self.send(Method::Get, &url, None).await?; | |
| 105 | + | let logs = body["result"].as_array().cloned().unwrap_or_default(); | |
| 106 | + | for log in &logs { | |
| 107 | + | cost += log["cost"].as_f64().unwrap_or(0.0); | |
| 108 | + | count += 1; | |
| 109 | + | } | |
| 110 | + | if logs.len() < 50 { | |
| 111 | + | break; | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | Ok((cost, count)) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// The account's billable usage this month, as Cloudflare reports it. | |
| 118 | + | async fn billable_usage(&self, from: &str, to: &str) -> Result<Vec<UsageRow>> { | |
| 119 | + | let body = self | |
| 120 | + | .send(Method::Get, &self.api(&format!("/billing/usage/paygo?from={from}&to={to}")), None) | |
| 121 | + | .await?; | |
| 122 | + | let rows = body["result"].as_array().cloned().unwrap_or_default(); | |
| 123 | + | Ok(rows.iter().filter_map(UsageRow::from_value).collect()) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Seconds g1t's containers ran since `since` (RFC 3339), across the | |
| 127 | + | /// account. | |
| 128 | + | async fn container_seconds(&self, since: &str, until: &str) -> Result<f64> { | |
| 129 | + | let query = "query ($account: String!, $since: Time!, $until: Time!) { | |
| 130 | + | viewer { accounts(filter: { accountTag: $account }) { | |
| 131 | + | containersMetricsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $since, datetime_leq: $until }) { | |
| 132 | + | sum { containerUptime } | |
| 133 | + | } | |
| 134 | + | } } | |
| 135 | + | }"; | |
| 136 | + | let body = self | |
| 137 | + | .send( | |
| 138 | + | Method::Post, | |
| 139 | + | "https://api.cloudflare.com/client/v4/graphql", | |
| 140 | + | Some(json!({ "query": query, "variables": { "account": self.account, "since": since, "until": until } })), | |
| 141 | + | ) | |
| 142 | + | .await?; | |
| 143 | + | let groups = body["data"]["viewer"]["accounts"][0]["containersMetricsAdaptiveGroups"] | |
| 144 | + | .as_array() | |
| 145 | + | .cloned() | |
| 146 | + | .unwrap_or_default(); | |
| 147 | + | Ok(groups.iter().map(|g| g["sum"]["containerUptime"].as_f64().unwrap_or(0.0)).sum()) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// One line of Cloudflare's billable usage. | |
| 152 | + | #[derive(Debug, Clone)] | |
| 153 | + | pub(crate) struct UsageRow { | |
| 154 | + | period_start: String, | |
| 155 | + | period_end: String, | |
| 156 | + | service: String, | |
| 157 | + | unit: String, | |
| 158 | + | quantity: f64, | |
| 159 | + | cost: f64, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | impl UsageRow { | |
| 163 | + | /// Read leniently: the API is new, and its field names are FOCUS's. | |
| 164 | + | fn from_value(row: &Value) -> Option<Self> { | |
| 165 | + | let text = |keys: &[&str]| keys.iter().find_map(|k| row[*k].as_str()).unwrap_or_default().to_owned(); | |
| 166 | + | let number = |keys: &[&str]| { | |
| 167 | + | keys.iter() | |
| 168 | + | .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.parse().ok()))) | |
| 169 | + | .unwrap_or(0.0) | |
| 170 | + | }; | |
| 171 | + | let service = text(&["ServiceName", "service_name", "service"]); | |
| 172 | + | if service.is_empty() { | |
| 173 | + | return None; | |
| 174 | + | } | |
| 175 | + | let family = text(&["ServiceFamilyName", "service_family_name"]); | |
| 176 | + | Some(UsageRow { | |
| 177 | + | period_start: text(&["ChargePeriodStart", "charge_period_start"]), | |
| 178 | + | period_end: text(&["ChargePeriodEnd", "charge_period_end"]), | |
| 179 | + | service: if family.is_empty() { service } else { format!("{family} / {service}") }, | |
| 180 | + | unit: text(&["ConsumedUnit", "PricingUnit", "consumed_unit"]), | |
| 181 | + | quantity: number(&["PricingQuantity", "ConsumedQuantity", "pricing_quantity"]), | |
| 182 | + | cost: number(&["ContractedCost", "BilledCost", "contracted_cost"]), | |
| 183 | + | }) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// What a cost should become from a measurement, or why not. | |
| 188 | + | pub(crate) fn adopt(current: f64, measured: f64) -> std::result::Result<Option<f64>, String> { | |
| 189 | + | if !measured.is_finite() || measured <= 0.0 { | |
| 190 | + | return Err("nothing to measure".into()); | |
| 191 | + | } | |
| 192 | + | let ratio = measured / current; | |
| 193 | + | if !(1.0 / MAX_FACTOR..=MAX_FACTOR).contains(&ratio) { | |
| 194 | + | return Err(format!("measured {measured:.4} against {current:.4}, too far off to adopt")); | |
| 195 | + | } | |
| 196 | + | Ok(((ratio - 1.0).abs() >= MIN_CHANGE).then_some(measured)) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[derive(Deserialize)] | |
| 200 | + | struct PriceRow { | |
| 201 | + | meter: String, | |
| 202 | + | title: String, | |
| 203 | + | unit: String, | |
| 204 | + | cost_micros: f64, | |
| 205 | + | markup_percent: u32, | |
| 206 | + | source: String, | |
| 207 | + | checked_at: Option<String>, | |
| 208 | + | updated_at: String, | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | #[derive(Deserialize)] | |
| 212 | + | struct ChangeRow { | |
| 213 | + | meter: String, | |
| 214 | + | old_cost_micros: f64, | |
| 215 | + | new_cost_micros: f64, | |
| 216 | + | markup_percent: u32, | |
| 217 | + | reason: String, | |
| 218 | + | created_at: String, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[derive(Deserialize)] | |
| 222 | + | struct Unsettled { | |
| 223 | + | id: String, | |
| 224 | + | workspace: String, | |
| 225 | + | repo: String, | |
| 226 | + | number: u32, | |
| 227 | + | task: String, | |
| 228 | + | model: String, | |
| 229 | + | token_hash: String, | |
| 230 | + | billed_to: Option<String>, | |
| 231 | + | session_id: String, | |
| 232 | + | created_at: String, | |
| 233 | + | finished_at: Option<String>, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[derive(Deserialize)] | |
| 237 | + | struct Charged { | |
| 238 | + | cost_micros: Option<i64>, | |
| 239 | + | description: String, | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | fn ms(timestamp: &str) -> u64 { | |
| 243 | + | // RFC 3339 in UTC, as g1t writes them. | |
| 244 | + | worker::js_sys::Date::parse(timestamp) as u64 | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | impl Billing { | |
| 248 | + | pub(crate) async fn prices(&self) -> Result<PriceBook> { | |
| 249 | + | let prices = self | |
| 250 | + | .db | |
| 251 | + | .prepare("SELECT * FROM prices ORDER BY rowid") | |
| 252 | + | .all() | |
| 253 | + | .await? | |
| 254 | + | .results::<PriceRow>()?; | |
| 255 | + | let changes = self | |
| 256 | + | .db | |
| 257 | + | .prepare("SELECT * FROM price_changes ORDER BY created_at DESC LIMIT 20") | |
| 258 | + | .all() | |
| 259 | + | .await? | |
| 260 | + | .results::<ChangeRow>()?; | |
| 261 | + | Ok(PriceBook { | |
| 262 | + | prices: prices | |
| 263 | + | .into_iter() | |
| 264 | + | .map(|row| Price { | |
| 265 | + | price_micros: Price::price_for(row.cost_micros, row.markup_percent), | |
| 266 | + | meter: row.meter, | |
| 267 | + | title: row.title, | |
| 268 | + | unit: row.unit, | |
| 269 | + | cost_micros: row.cost_micros, | |
| 270 | + | markup_percent: row.markup_percent, | |
| 271 | + | source: row.source, | |
| 272 | + | checked_at: row.checked_at, | |
| 273 | + | updated_at: row.updated_at, | |
| 274 | + | }) | |
| 275 | + | .collect(), | |
| 276 | + | changes: changes | |
| 277 | + | .into_iter() | |
| 278 | + | .map(|row| PriceChange { | |
| 279 | + | meter: row.meter, | |
| 280 | + | old_cost_micros: row.old_cost_micros, | |
| 281 | + | new_cost_micros: row.new_cost_micros, | |
| 282 | + | markup_percent: row.markup_percent, | |
| 283 | + | reason: row.reason, | |
| 284 | + | created_at: row.created_at, | |
| 285 | + | }) | |
| 286 | + | .collect(), | |
| 287 | + | model_margin_percent: self.margin_percent, | |
| 288 | + | }) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// A meter's cost and price per unit, from the book. | |
| 292 | + | pub(crate) async fn price(&self, meter: &str) -> Result<Option<(f64, f64)>> { | |
| 293 | + | #[derive(Deserialize)] | |
| 294 | + | struct Row { | |
| 295 | + | cost_micros: f64, | |
| 296 | + | markup_percent: u32, | |
| 297 | + | } | |
| 298 | + | Ok(self | |
| 299 | + | .db | |
| 300 | + | .prepare("SELECT cost_micros, markup_percent FROM prices WHERE meter = ?") | |
| 301 | + | .bind(&[meter.into()])? | |
| 302 | + | .first::<Row>(None) | |
| 303 | + | .await? | |
| 304 | + | .map(|row| (row.cost_micros, Price::price_for(row.cost_micros, row.markup_percent)))) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | /// Corrects finished runs to what AI Gateway priced them at, and | |
| 308 | + | /// charges runs whose sandbox died before reporting. | |
| 309 | + | pub(crate) async fn settle_runs(&self, keeper: &Keeper) -> Result<()> { | |
| 310 | + | if keeper.token.is_none() || keeper.gateway.is_empty() { | |
| 311 | + | return Ok(()); | |
| 312 | + | } | |
| 313 | + | let now = now_ms(); | |
| 314 | + | let runs = self | |
| 315 | + | .db | |
| 316 | + | .prepare( | |
| 317 | + | "SELECT id, workspace, repo, number, task, model, token_hash, billed_to, session_id, created_at, finished_at | |
| 318 | + | FROM runs | |
| 319 | + | WHERE session_id IS NOT NULL AND settled_at IS NULL | |
| 320 | + | AND ((finished_at IS NOT NULL AND finished_at < ?1) OR created_at < ?2) | |
| 321 | + | ORDER BY created_at LIMIT 10", | |
| 322 | + | ) | |
| 323 | + | .bind(&[rfc3339(now - SETTLE_AFTER_MS).into(), rfc3339(now - ABANDONED_AFTER_MS).into()])? | |
| 324 | + | .all() | |
| 325 | + | .await? | |
| 326 | + | .results::<Unsettled>()?; | |
| 327 | + | for run in runs { | |
| 328 | + | let (cost_usd, requests) = match keeper.session_cost(&run.session_id).await { | |
| 329 | + | Ok(found) => found, | |
| 330 | + | Err(error) => { | |
| 331 | + | worker::console_error!("could not read gateway logs for {}: {error}", run.id); | |
| 332 | + | continue; | |
| 333 | + | } | |
| 334 | + | }; | |
| 335 | + | let since = ms(run.finished_at.as_deref().unwrap_or(&run.created_at)); | |
| 336 | + | if requests == 0 && now.saturating_sub(since) < GIVE_UP_AFTER_MS { | |
| 337 | + | continue; | |
| 338 | + | } | |
| 339 | + | self.settle(&run, cost_usd, requests).await?; | |
| 340 | + | } | |
| 341 | + | Ok(()) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | async fn settle(&self, run: &Unsettled, cost_usd: f64, requests: u32) -> Result<()> { | |
| 345 | + | let row = RunRow { | |
| 346 | + | workspace: run.workspace.clone(), | |
| 347 | + | repo: run.repo.clone(), | |
| 348 | + | number: run.number, | |
| 349 | + | task: run.task.clone(), | |
| 350 | + | model: run.model.clone(), | |
| 351 | + | token_hash: run.token_hash.clone(), | |
| 352 | + | billed_to: run.billed_to.clone(), | |
| 353 | + | }; | |
| 354 | + | let gateway_micros = charge_micros(cost_usd, 0); | |
| 355 | + | let charged = self | |
| 356 | + | .db | |
| 357 | + | .prepare("SELECT cost_micros, description FROM ledger WHERE reference = ?") | |
| 358 | + | .bind(&[run.id.as_str().into()])? | |
| 359 | + | .first::<Charged>(None) | |
| 360 | + | .await?; | |
| 361 | + | let charge_for = |micros: i64| { | |
| 362 | + | if self.free { | |
| 363 | + | 0 | |
| 364 | + | } else { | |
| 365 | + | charge_micros(micros as f64 / MICROS_PER_DOLLAR as f64, self.margin_percent) | |
| 366 | + | } | |
| 367 | + | }; | |
| 368 | + | let settled_at = rfc3339(now_ms()); | |
| 369 | + | // Claim it, so two crons never settle it twice. | |
| 370 | + | let claimed = self | |
| 371 | + | .db | |
| 372 | + | .prepare("UPDATE runs SET settled_at = ?, gateway_cost_micros = ?, finished_at = COALESCE(finished_at, ?) WHERE id = ? AND settled_at IS NULL RETURNING id") | |
| 373 | + | .bind(&[ | |
| 374 | + | settled_at.as_str().into(), | |
| 375 | + | (gateway_micros as f64).into(), | |
| 376 | + | settled_at.as_str().into(), | |
| 377 | + | run.id.as_str().into(), | |
| 378 | + | ])? | |
| 379 | + | .first::<Value>(None) | |
| 380 | + | .await?; | |
| 381 | + | if claimed.is_none() || requests == 0 { | |
| 382 | + | return Ok(()); | |
| 383 | + | } | |
| 384 | + | let free_note = if self.free { " (free while g1t is being built out)" } else { "" }; | |
| 385 | + | match charged { | |
| 386 | + | // Never reported: charged now, from the gateway's figure. | |
| 387 | + | None => { | |
| 388 | + | let description = format!( | |
| 389 | + | "Work on {}#{}, settled from AI Gateway after the sandbox stopped without reporting{free_note}", | |
| 390 | + | run.repo, run.number | |
| 391 | + | ); | |
| 392 | + | self.enter(&run.workspace, EntryKind::Usage, -charge_for(gateway_micros), &description, &run.id, Some(&row), Some(gateway_micros), None, None) | |
| 393 | + | .await?; | |
| 394 | + | } | |
| 395 | + | Some(charged) => { | |
| 396 | + | let reported = charged.cost_micros.unwrap_or(0); | |
| 397 | + | let delta = gateway_micros - reported; | |
| 398 | + | if delta == 0 { | |
| 399 | + | return Ok(()); | |
| 400 | + | } | |
| 401 | + | let amount = -(charge_for(gateway_micros) - charge_for(reported)); | |
| 402 | + | let description = format!( | |
| 403 | + | "Correction to “{}”: AI Gateway priced its {requests} model requests at {}, not {}", | |
| 404 | + | charged.description, | |
| 405 | + | crate::features::dollars(gateway_micros), | |
| 406 | + | crate::features::dollars(reported), | |
| 407 | + | ); | |
| 408 | + | self.enter( | |
| 409 | + | &run.workspace, | |
| 410 | + | EntryKind::Usage, | |
| 411 | + | amount, | |
| 412 | + | &description, | |
| 413 | + | &format!("{}/settled", run.id), | |
| 414 | + | Some(&row), | |
| 415 | + | Some(delta), | |
| 416 | + | None, | |
| 417 | + | None, | |
| 418 | + | ) | |
| 419 | + | .await?; | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | Ok(()) | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | /// Checks each cost against what Cloudflare billed this month, and | |
| 426 | + | /// moves the ones that changed. | |
| 427 | + | pub(crate) async fn reconcile(&self, keeper: &Keeper) -> Result<()> { | |
| 428 | + | if keeper.token.is_none() { | |
| 429 | + | return Ok(()); | |
| 430 | + | } | |
| 431 | + | let now = rfc3339(now_ms()); | |
| 432 | + | let month_start = format!("{}-01", &now[..7]); | |
| 433 | + | let today = &now[..10]; | |
| 434 | + | let rows = keeper.billable_usage(&month_start, today).await?; | |
| 435 | + | for row in &rows { | |
| 436 | + | self.db | |
| 437 | + | .prepare( | |
| 438 | + | "INSERT INTO cloudflare_usage (period_start, period_end, service, unit, quantity, cost_usd, fetched_at) | |
| 439 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 440 | + | ON CONFLICT (period_start, service, unit) DO UPDATE SET | |
| 441 | + | period_end = ?2, quantity = ?5, cost_usd = ?6, fetched_at = ?7", | |
| 442 | + | ) | |
| 443 | + | .bind(&[ | |
| 444 | + | row.period_start.as_str().into(), | |
| 445 | + | row.period_end.as_str().into(), | |
| 446 | + | row.service.as_str().into(), | |
| 447 | + | row.unit.as_str().into(), | |
| 448 | + | row.quantity.into(), | |
| 449 | + | row.cost.into(), | |
| 450 | + | now.as_str().into(), | |
| 451 | + | ])? | |
| 452 | + | .run() | |
| 453 | + | .await?; | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | let matching = |service: &str, unit: Option<&str>| -> (f64, f64) { | |
| 457 | + | rows.iter() | |
| 458 | + | .filter(|r| r.service.to_lowercase().contains(service)) | |
| 459 | + | .filter(|r| unit.is_none_or(|u| r.unit.to_lowercase().contains(u))) | |
| 460 | + | .fold((0.0, 0.0), |(q, c), r| (q + r.quantity, c + r.cost)) | |
| 461 | + | }; | |
| 462 | + | ||
| 463 | + | // Containers: what they cost, over the seconds they ran. | |
| 464 | + | let (_, container_cost) = matching("container", None); | |
| 465 | + | if container_cost >= MIN_MEASURED_USD { | |
| 466 | + | let seconds = keeper.container_seconds(&format!("{month_start}T00:00:00Z"), &now).await?; | |
| 467 | + | if seconds > 0.0 { | |
| 468 | + | let per_second = container_cost * MICROS_PER_DOLLAR as f64 / seconds; | |
| 469 | + | for meter in ["sandbox_second", "build_second"] { | |
| 470 | + | self.measure(meter, per_second, &format!("Cloudflare billed ${container_cost:.2} for {seconds:.0} container-seconds this month")).await?; | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | // Workers for Platforms: per million requests and CPU milliseconds. | |
| 475 | + | for (meter, unit, scale) in [("app_requests", "request", 1e6), ("app_cpu", "ms", 1e6)] { | |
| 476 | + | let (quantity, cost) = matching("workers for platforms", Some(unit)); | |
| 477 | + | if cost >= MIN_MEASURED_USD && quantity > 0.0 { | |
| 478 | + | let per = cost * MICROS_PER_DOLLAR as f64 / quantity * scale; | |
| 479 | + | self.measure(meter, per, &format!("Cloudflare billed ${cost:.2} for {quantity:.0} {unit}s this month")).await?; | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | self.db | |
| 483 | + | .prepare("UPDATE prices SET checked_at = ?") | |
| 484 | + | .bind(&[now.as_str().into()])? | |
| 485 | + | .run() | |
| 486 | + | .await?; | |
| 487 | + | Ok(()) | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | /// Moves a meter's cost to a measurement, if it is sound and different. | |
| 491 | + | async fn measure(&self, meter: &str, measured: f64, reason: &str) -> Result<()> { | |
| 492 | + | let Some((current, _)) = self.price(meter).await? else { | |
| 493 | + | return Ok(()); | |
| 494 | + | }; | |
| 495 | + | match adopt(current, measured) { | |
| 496 | + | Err(why) => worker::console_log!("{meter}: {why}"), | |
| 497 | + | Ok(None) => {} | |
| 498 | + | Ok(Some(cost)) => { | |
| 499 | + | let now = now_ms(); | |
| 500 | + | self.db | |
| 501 | + | .batch(vec![ | |
| 502 | + | self.db | |
| 503 | + | .prepare("UPDATE prices SET cost_micros = ?, source = 'cloudflare', updated_at = ? WHERE meter = ?") | |
| 504 | + | .bind(&[cost.into(), rfc3339(now).into(), meter.into()])?, | |
| 505 | + | self.db | |
| 506 | + | .prepare( | |
| 507 | + | "INSERT INTO price_changes (id, meter, old_cost_micros, new_cost_micros, markup_percent, reason, created_at) | |
| 508 | + | SELECT ?, meter, ?, ?, markup_percent, ?, ? FROM prices WHERE meter = ?", | |
| 509 | + | ) | |
| 510 | + | .bind(&[ | |
| 511 | + | new_id("prc", now).into(), | |
| 512 | + | current.into(), | |
| 513 | + | cost.into(), | |
| 514 | + | reason.into(), | |
| 515 | + | rfc3339(now).into(), | |
| 516 | + | meter.into(), | |
| 517 | + | ])?, | |
| 518 | + | ]) | |
| 519 | + | .await?; | |
| 520 | + | } | |
| 521 | + | } | |
| 522 | + | Ok(()) | |
| 523 | + | } | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | #[cfg(test)] | |
| 527 | + | mod tests { | |
| 528 | + | use super::*; | |
| 529 | + | ||
| 530 | + | #[test] | |
| 531 | + | fn small_moves_are_noise_and_wild_ones_are_not_believed() { | |
| 532 | + | assert_eq!(adopt(21.0, 21.2), Ok(None)); | |
| 533 | + | assert_eq!(adopt(21.0, 25.0), Ok(Some(25.0))); | |
| 534 | + | assert_eq!(adopt(21.0, 15.0), Ok(Some(15.0))); | |
| 535 | + | assert!(adopt(21.0, 200.0).is_err()); | |
| 536 | + | assert!(adopt(21.0, 0.0).is_err()); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn usage_rows_are_read_by_their_focus_names() { | |
| 541 | + | let row = UsageRow::from_value(&json!({ | |
| 542 | + | "ServiceFamilyName": "Containers", | |
| 543 | + | "ServiceName": "Memory", | |
| 544 | + | "ConsumedUnit": "GiB-seconds", | |
| 545 | + | "PricingQuantity": "1200.5", | |
| 546 | + | "ContractedCost": 0.003, | |
| 547 | + | "ChargePeriodStart": "2026-10-01", | |
| 548 | + | })) | |
| 549 | + | .unwrap(); | |
| 550 | + | assert_eq!(row.service, "Containers / Memory"); | |
| 551 | + | assert_eq!(row.quantity, 1200.5); | |
| 552 | + | assert_eq!(row.cost, 0.003); | |
| 553 | + | assert!(UsageRow::from_value(&json!({ "nothing": 1 })).is_none()); | |
| 554 | + | } | |
| 555 | + | } |
| 17 | 17 | //! the methods and their arguments. | |
| 18 | 18 | ||
| 19 | 19 | mod features; | |
| 20 | + | mod keeper; | |
| 20 | 21 | mod limits; | |
| 21 | 22 | mod stripe; | |
| 22 | 23 | ||
| 27 | 28 | use serde::Deserialize; | |
| 28 | 29 | use sha2::{Digest, Sha256}; | |
| 29 | 30 | use worker::wasm_bindgen::JsValue; | |
| 30 | − | use worker::{Context, D1Database, Env, Request, Response, Result, event}; | |
| 31 | + | use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event}; | |
| 31 | 32 | ||
| 32 | 33 | use stripe::Stripe; | |
| 33 | 34 | ||
| 597 | 598 | let token = hex::encode(bytes); | |
| 598 | 599 | self.db | |
| 599 | 600 | .prepare( | |
| 600 | − | "INSERT INTO runs (id, workspace, repo, number, task, model, token_hash, created_at, billed_to) | |
| 601 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 601 | + | "INSERT INTO runs (id, workspace, repo, number, task, model, token_hash, created_at, billed_to, session_id) | |
| 602 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 602 | 603 | ) | |
| 603 | 604 | .bind(&[ | |
| 604 | 605 | run_id.as_str().into(), | |
| 610 | 611 | hash(&token).into(), | |
| 611 | 612 | rfc3339(now).into(), | |
| 612 | 613 | if a.billed_to == "workspace" { "workspace" } else { "g1t" }.into(), | |
| 614 | + | optional(a.session.as_deref().filter(|_| a.billed_to != "workspace")), | |
| 613 | 615 | ])? | |
| 614 | 616 | .run() | |
| 615 | 617 | .await?; | |
| 719 | 721 | .await? | |
| 720 | 722 | .map_or(seconds, |used| used.seconds); | |
| 721 | 723 | let billable = sandbox_billable(after - seconds, seconds); | |
| 722 | − | let charge = if self.free { 0 } else { billable * sandbox_allowance::MICROS_PER_SECOND }; | |
| 724 | + | // From the price book, which follows what Cloudflare bills g1t. | |
| 725 | + | let (cost_per_second, price_per_second) = self.price("sandbox_second").await?.unwrap_or(( | |
| 726 | + | sandbox_allowance::COST_MICROS_PER_SECOND as f64, | |
| 727 | + | sandbox_allowance::MICROS_PER_SECOND as f64, | |
| 728 | + | )); | |
| 729 | + | let charge = if self.free { 0 } else { (billable as f64 * price_per_second).ceil() as i64 }; | |
| 723 | 730 | let mut description = format!("{}: {} of sandbox time", a.description, duration(seconds)); | |
| 724 | 731 | if billable < seconds { | |
| 725 | 732 | description.push_str(if billable == 0 { | |
| 746 | 753 | (-(charge as f64)).into(), | |
| 747 | 754 | description.as_str().into(), | |
| 748 | 755 | optional(a.repo.as_deref()), | |
| 749 | − | ((seconds * sandbox_allowance::COST_MICROS_PER_SECOND) as f64).into(), | |
| 756 | + | (seconds as f64 * cost_per_second).ceil().into(), | |
| 750 | 757 | a.reference.as_str().into(), | |
| 751 | 758 | timestamp.as_str().into(), | |
| 752 | 759 | ])?, | |
| 793 | 800 | ) | |
| 794 | 801 | } | |
| 795 | 802 | ||
| 803 | + | impl Billing { | |
| 804 | + | fn from_env(env: &Env) -> Result<Self> { | |
| 805 | + | Ok(Billing { | |
| 806 | + | db: env.d1("DB")?, | |
| 807 | + | stripe: env | |
| 808 | + | .secret("STRIPE_SECRET_KEY") | |
| 809 | + | .ok() | |
| 810 | + | .map(|key| key.to_string()) | |
| 811 | + | .filter(|key| !key.is_empty()) | |
| 812 | + | .map(Stripe::new), | |
| 813 | + | margin_percent: env | |
| 814 | + | .var("MARGIN_PERCENT") | |
| 815 | + | .ok() | |
| 816 | + | .and_then(|percent| percent.to_string().parse().ok()) | |
| 817 | + | .unwrap_or(20), | |
| 818 | + | orchestration_fee_micros: env | |
| 819 | + | .var("ORCHESTRATION_FEE_MICROS") | |
| 820 | + | .ok() | |
| 821 | + | .and_then(|fee| fee.to_string().parse().ok()) | |
| 822 | + | .unwrap_or(100_000), | |
| 823 | + | free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"), | |
| 824 | + | ceilings: limits::Ceilings::from_env(&env), | |
| 825 | + | deployments_monthly_cents: env | |
| 826 | + | .var("DEPLOYMENTS_MONTHLY_CENTS") | |
| 827 | + | .ok() | |
| 828 | + | .and_then(|cents| cents.to_string().parse().ok()) | |
| 829 | + | .unwrap_or(500), | |
| 830 | + | trial: { | |
| 831 | + | let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()); | |
| 832 | + | match ( | |
| 833 | + | number("TRIAL_WORKSPACE_MICROS"), | |
| 834 | + | number("TRIAL_TOTAL_MICROS"), | |
| 835 | + | env.var("TRIAL_UNTIL").ok().map(|v| v.to_string()), | |
| 836 | + | ) { | |
| 837 | + | (Some(per_workspace_micros), Some(total_micros), Some(until)) | |
| 838 | + | if per_workspace_micros > 0 && !until.is_empty() => | |
| 839 | + | { | |
| 840 | + | Some(TrialConfig { | |
| 841 | + | per_workspace_micros, | |
| 842 | + | total_micros, | |
| 843 | + | until, | |
| 844 | + | }) | |
| 845 | + | } | |
| 846 | + | _ => None, | |
| 847 | + | } | |
| 848 | + | }, | |
| 849 | + | }) | |
| 850 | + | } | |
| 851 | + | } | |
| 852 | + | ||
| 853 | + | #[event(scheduled)] | |
| 854 | + | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 855 | + | let Ok(billing) = Billing::from_env(&env) else { | |
| 856 | + | return; | |
| 857 | + | }; | |
| 858 | + | let keeper = keeper::Keeper::from_env(&env); | |
| 859 | + | if let Err(error) = billing.settle_runs(&keeper).await { | |
| 860 | + | worker::console_error!("settling runs failed: {error}"); | |
| 861 | + | } | |
| 862 | + | // Once a day: check every cost against what Cloudflare billed. | |
| 863 | + | if event.cron() == keeper::DAILY { | |
| 864 | + | if let Err(error) = billing.reconcile(&keeper).await { | |
| 865 | + | worker::console_error!("checking costs against Cloudflare failed: {error}"); | |
| 866 | + | } | |
| 867 | + | } | |
| 868 | + | } | |
| 869 | + | ||
| 796 | 870 | #[event(fetch)] | |
| 797 | 871 | async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 798 | 872 | let Some(method) = rpc_method(&request) else { | |
| 799 | 873 | return Response::error("Not found", 404); | |
| 800 | 874 | }; | |
| 801 | 875 | let body: serde_json::Value = request.json().await?; | |
| 802 | − | let billing = Billing { | |
| 803 | − | db: env.d1("DB")?, | |
| 804 | − | stripe: env | |
| 805 | − | .secret("STRIPE_SECRET_KEY") | |
| 806 | − | .ok() | |
| 807 | − | .map(|key| key.to_string()) | |
| 808 | − | .filter(|key| !key.is_empty()) | |
| 809 | − | .map(Stripe::new), | |
| 810 | − | margin_percent: env | |
| 811 | − | .var("MARGIN_PERCENT") | |
| 812 | − | .ok() | |
| 813 | − | .and_then(|percent| percent.to_string().parse().ok()) | |
| 814 | − | .unwrap_or(20), | |
| 815 | − | orchestration_fee_micros: env | |
| 816 | − | .var("ORCHESTRATION_FEE_MICROS") | |
| 817 | − | .ok() | |
| 818 | − | .and_then(|fee| fee.to_string().parse().ok()) | |
| 819 | − | .unwrap_or(100_000), | |
| 820 | − | free: env.var("FREE_WHILE_BUILDING").is_ok_and(|v| v.to_string() == "true"), | |
| 821 | − | ceilings: limits::Ceilings::from_env(&env), | |
| 822 | − | deployments_monthly_cents: env | |
| 823 | − | .var("DEPLOYMENTS_MONTHLY_CENTS") | |
| 824 | − | .ok() | |
| 825 | − | .and_then(|cents| cents.to_string().parse().ok()) | |
| 826 | − | .unwrap_or(500), | |
| 827 | − | trial: { | |
| 828 | − | let number = |name: &str| env.var(name).ok().and_then(|v| v.to_string().parse::<i64>().ok()); | |
| 829 | − | match ( | |
| 830 | − | number("TRIAL_WORKSPACE_MICROS"), | |
| 831 | − | number("TRIAL_TOTAL_MICROS"), | |
| 832 | − | env.var("TRIAL_UNTIL").ok().map(|v| v.to_string()), | |
| 833 | − | ) { | |
| 834 | − | (Some(per_workspace_micros), Some(total_micros), Some(until)) | |
| 835 | − | if per_workspace_micros > 0 && !until.is_empty() => | |
| 836 | − | { | |
| 837 | − | Some(TrialConfig { | |
| 838 | − | per_workspace_micros, | |
| 839 | − | total_micros, | |
| 840 | − | until, | |
| 841 | − | }) | |
| 842 | − | } | |
| 843 | − | _ => None, | |
| 844 | − | } | |
| 845 | − | }, | |
| 846 | − | }; | |
| 876 | + | let billing = Billing::from_env(&env)?; | |
| 847 | 877 | match method.as_str() { | |
| 848 | 878 | "status" => reply(&billing.status()), | |
| 849 | 879 | "account" => reply(&billing.account(args(body)?).await?), | |
| 865 | 895 | "limit" => reply(&billing.limit(args(body)?).await?), | |
| 866 | 896 | "check_limit" => reply(&billing.check_limit(args(body)?).await?), | |
| 867 | 897 | "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?), | |
| 898 | + | "prices" => reply(&billing.prices().await?), | |
| 899 | + | "note_pending" => reply(&billing.note_pending(args(body)?).await?), | |
| 868 | 900 | _ => Response::error("Unknown method", 404), | |
| 869 | 901 | } | |
| 870 | 902 | } |
| 21 | 21 | //! exemption from the ceiling. Test-mode payments are not money, so they | |
| 22 | 22 | //! do not raise trust. | |
| 23 | 23 | ||
| 24 | − | use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, LimitState, SetSpendLimitArgs, Trust}; | |
| 24 | + | use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, LimitState, SetSpendLimitArgs, Trust}; | |
| 25 | 25 | use g1t_contracts::time::rfc3339; | |
| 26 | 26 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 27 | 27 | use g1t_kit::now_ms; | |
| 124 | 124 | .first::<Month>(None) | |
| 125 | 125 | .await?; | |
| 126 | 126 | let (used, paid_month) = month.map_or((0, 0), |m| (m.used.unwrap_or(0), m.paid.unwrap_or(0))); | |
| 127 | + | // And what is metered but not charged until the month closes. | |
| 128 | + | let pending = self | |
| 129 | + | .db | |
| 130 | + | .prepare("SELECT SUM(charge_micros) AS paid FROM pending_usage WHERE workspace = ? AND month = ?") | |
| 131 | + | .bind(&[workspace.as_str().into(), month_start[..7].into()])? | |
| 132 | + | .first::<Paid>(None) | |
| 133 | + | .await? | |
| 134 | + | .and_then(|row| row.paid) | |
| 135 | + | .unwrap_or(0); | |
| 136 | + | let used = used + pending; | |
| 127 | 137 | // Test-mode payments are not money: they pay nothing off. | |
| 128 | 138 | let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live); | |
| 129 | 139 | let exposure = (used - if live { paid_month } else { 0 }).max(0); | |
| 216 | 226 | Ok(Outcome::Ok(self.limit_of(&workspace).await?)) | |
| 217 | 227 | } | |
| 218 | 228 | ||
| 229 | + | pub(crate) async fn note_pending(&self, a: NotePendingArgs) -> Result<bool> { | |
| 230 | + | let now = rfc3339(now_ms()); | |
| 231 | + | let charge = crate::charge_micros(a.cost_micros.max(0) as f64 / g1t_contracts::billing::MICROS_PER_DOLLAR as f64, self.margin_percent); | |
| 232 | + | self.db | |
| 233 | + | .prepare( | |
| 234 | + | "INSERT INTO pending_usage (workspace, source, month, charge_micros, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 235 | + | ON CONFLICT (workspace, source, month) DO UPDATE SET charge_micros = ?4, updated_at = ?5", | |
| 236 | + | ) | |
| 237 | + | .bind(&[ | |
| 238 | + | a.workspace.to_lowercase().into(), | |
| 239 | + | a.source.as_str().into(), | |
| 240 | + | now[..7].into(), | |
| 241 | + | (charge as f64).into(), | |
| 242 | + | now.as_str().into(), | |
| 243 | + | ])? | |
| 244 | + | .run() | |
| 245 | + | .await?; | |
| 246 | + | Ok(true) | |
| 247 | + | } | |
| 248 | + | ||
| 219 | 249 | pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> { | |
| 220 | 250 | Ok(Outcome::Ok(self.limit_of(&a.workspace).await?)) | |
| 221 | 251 | } |
| 47 | 47 | "TRIAL_UNTIL": "2026-10-23T06:59:59Z", | |
| 48 | 48 | // The Deployments plan's monthly price, in cents. Turning the | |
| 49 | 49 | // feature on starts it; FREE_WHILE_BUILDING does not cover it. | |
| 50 | − | "DEPLOYMENTS_MONTHLY_CENTS": "500" | |
| 50 | + | "DEPLOYMENTS_MONTHLY_CENTS": "500", | |
| 51 | + | // Where the keeper reads what g1t pays (src/keeper.rs). Its token, | |
| 52 | + | // CLOUDFLARE_USAGE_TOKEN, is a secret: Billing, Account Analytics | |
| 53 | + | // and AI Gateway, read only. | |
| 54 | + | "CLOUDFLARE_ACCOUNT_ID": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 55 | + | "AI_GATEWAY_ID": "g1t" | |
| 51 | 56 | }, | |
| 57 | + | // Settling runs every 15 minutes; checking costs daily (keeper::DAILY). | |
| 58 | + | "triggers": { "crons": ["*/15 * * * *", "17 4 * * *"] }, | |
| 52 | 59 | // Secret: STRIPE_SECRET_KEY. Without it nothing is charged and the | |
| 53 | 60 | // runner decides who may start agents some other way. | |
| 54 | 61 | "observability": { "enabled": true } |
| 823 | 823 | ||
| 824 | 824 | /** Each build is charged by the second at the container price plus the margin. */ | |
| 825 | 825 | private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> { | |
| 826 | − | const cost = Math.ceil(seconds) * DEPLOYMENTS_ALLOWANCE.microsPerBuildSecond; | |
| 826 | + | const costs = await this.costs(); | |
| 827 | + | const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond); | |
| 827 | 828 | if (cost <= 0) return; | |
| 828 | 829 | const what = | |
| 829 | 830 | row.kind === "preview" | |
| 846 | 847 | .run(); | |
| 847 | 848 | } | |
| 848 | 849 | ||
| 850 | + | /** | |
| 851 | + | * What each unit costs g1t now, from billing's price book, which follows | |
| 852 | + | * what Cloudflare bills. The plan's figures if billing cannot say. | |
| 853 | + | */ | |
| 854 | + | private async costs(): Promise<{ buildSecond: number; millionRequests: number; millionCpuMs: number; appMonth: number }> { | |
| 855 | + | const a = DEPLOYMENTS_ALLOWANCE; | |
| 856 | + | const book = await billingClient(this.env.BILLING) | |
| 857 | + | .prices() | |
| 858 | + | .catch(() => null); | |
| 859 | + | const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback; | |
| 860 | + | return { | |
| 861 | + | buildSecond: cost("build_second", a.microsPerBuildSecond), | |
| 862 | + | millionRequests: cost("app_requests", a.microsPerMillionRequests), | |
| 863 | + | millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs), | |
| 864 | + | appMonth: cost("app_month", a.microsPerAppMonth), | |
| 865 | + | }; | |
| 866 | + | } | |
| 867 | + | ||
| 849 | 868 | /** Remembers the most apps the workspace had up at once this month. */ | |
| 850 | 869 | private async notePeak(workspace: string): Promise<void> { | |
| 851 | 870 | await this.db | |
| 1061 | 1080 | } | |
| 1062 | 1081 | } | |
| 1063 | 1082 | for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace); | |
| 1083 | + | // What this month's traffic past the plan will cost, so the workspace's | |
| 1084 | + | // limit counts it now rather than when the month closes. | |
| 1085 | + | const costs = await this.costs(); | |
| 1086 | + | const a = DEPLOYMENTS_ALLOWANCE; | |
| 1087 | + | for (const workspace of perWorkspace.keys()) { | |
| 1088 | + | const meter = await this.db | |
| 1089 | + | .prepare("SELECT requests, cpu_ms, peak_apps FROM meters WHERE namespace = ? AND month = ?") | |
| 1090 | + | .bind(workspace, month()) | |
| 1091 | + | .first<{ requests: number; cpu_ms: number; peak_apps: number }>(); | |
| 1092 | + | if (!meter) continue; | |
| 1093 | + | const cost = Math.ceil( | |
| 1094 | + | (Math.max(0, meter.requests - a.requests) / 1_000_000) * costs.millionRequests + | |
| 1095 | + | (Math.max(0, meter.cpu_ms - a.cpuMs) / 1_000_000) * costs.millionCpuMs + | |
| 1096 | + | Math.max(0, meter.peak_apps - a.apps) * costs.appMonth, | |
| 1097 | + | ); | |
| 1098 | + | await billingClient(this.env.BILLING) | |
| 1099 | + | .notePending(workspace, "deployments", cost) | |
| 1100 | + | .catch((error) => console.error("could not note pending usage", error)); | |
| 1101 | + | } | |
| 1064 | 1102 | } | |
| 1065 | 1103 | ||
| 1066 | 1104 | /** Previews no one has visited in their project's idle days. */ | |
| 1082 | 1120 | .bind(month()) | |
| 1083 | 1121 | .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>(); | |
| 1084 | 1122 | const a = DEPLOYMENTS_ALLOWANCE; | |
| 1123 | + | const costs = await this.costs(); | |
| 1085 | 1124 | for (const meter of due.results) { | |
| 1086 | 1125 | const extraRequests = Math.max(0, meter.requests - a.requests); | |
| 1087 | 1126 | const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs); | |
| 1088 | 1127 | const extraApps = Math.max(0, meter.peak_apps - a.apps); | |
| 1089 | 1128 | const cost = Math.ceil( | |
| 1090 | − | (extraRequests / 1_000_000) * a.microsPerMillionRequests + | |
| 1091 | − | (extraCpu / 1_000_000) * a.microsPerMillionCpuMs + | |
| 1092 | − | extraApps * a.microsPerAppMonth, | |
| 1129 | + | (extraRequests / 1_000_000) * costs.millionRequests + | |
| 1130 | + | (extraCpu / 1_000_000) * costs.millionCpuMs + | |
| 1131 | + | extraApps * costs.appMonth, | |
| 1093 | 1132 | ); | |
| 1094 | 1133 | if (cost > 0) { | |
| 1095 | 1134 | const parts = [ |
| 104 | 104 | issue: Option<String>, | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | + | /// A model session's public id: the start of its token's hash. | |
| 108 | + | fn session_id(token_hash: &str) -> String { | |
| 109 | + | format!("ms_{}", &token_hash[..token_hash.len().min(24)]) | |
| 110 | + | } | |
| 111 | + | ||
| 107 | 112 | #[derive(Deserialize)] | |
| 108 | 113 | struct SessionRow { | |
| 114 | + | token_hash: String, | |
| 109 | 115 | workspace: String, | |
| 110 | 116 | connection_id: Option<String>, | |
| 111 | 117 | repo: String, | |
| 1219 | 1225 | ])?, | |
| 1220 | 1226 | ]) | |
| 1221 | 1227 | .await?; | |
| 1228 | + | let id = session_id(&crypto::sha256_hex(&token)); | |
| 1222 | 1229 | Ok(Outcome::Ok(ModelSession { | |
| 1223 | 1230 | token, | |
| 1224 | 1231 | billed_to: if connection.is_some() { "workspace" } else { "g1t" }.to_owned(), | |
| 1225 | 1232 | provider_name: connection.map(|row| row.name.clone()), | |
| 1226 | 1233 | model, | |
| 1234 | + | id, | |
| 1227 | 1235 | })) | |
| 1228 | 1236 | } | |
| 1229 | 1237 | ||
| 1247 | 1255 | repo: session.repo, | |
| 1248 | 1256 | number: session.number, | |
| 1249 | 1257 | task: session.task, | |
| 1258 | + | session: session_id(&session.token_hash), | |
| 1250 | 1259 | base_url: None, | |
| 1251 | 1260 | api_key: None, | |
| 1252 | 1261 | auth_header: None, |
| 5 | 5 | ||
| 6 | 6 | import { presentedToken, upstreamRequest } from "./route.ts"; | |
| 7 | 7 | ||
| 8 | − | const run = { workspace: "acme", repo: "acme/web", number: 7, task: "implement", baseUrl: null, apiKey: null, authHeader: null, api: "anthropic" as const, model: null, official: false, provider: "g1t" }; | |
| 8 | + | const run = { workspace: "acme", repo: "acme/web", number: 7, task: "implement", session: "ms_abc", baseUrl: null, apiKey: null, authHeader: null, api: "anthropic" as const, model: null, official: false, provider: "g1t" }; | |
| 9 | 9 | const hosted = { AI_GATEWAY_ID: "g1t", CLOUDFLARE_ACCOUNT_ID: "acct", AI_GATEWAY_TOKEN: "gw-token" }; | |
| 10 | 10 | ||
| 11 | 11 | function incoming(): Headers { | |
| 35 | 35 | workspace: "acme", | |
| 36 | 36 | repo: "acme/web", | |
| 37 | 37 | pull: 7, | |
| 38 | + | session: "ms_abc", | |
| 38 | 39 | }); | |
| 39 | 40 | }); | |
| 40 | 41 |
| 55 | 55 | // be read per kind of work, workspace, repository and pull request. | |
| 56 | 56 | headers.set( | |
| 57 | 57 | "cf-aig-metadata", | |
| 58 | − | JSON.stringify({ task: upstream.task, workspace: upstream.workspace, repo: upstream.repo, pull: upstream.number }), | |
| 58 | + | JSON.stringify({ | |
| 59 | + | task: upstream.task, | |
| 60 | + | workspace: upstream.workspace, | |
| 61 | + | repo: upstream.repo, | |
| 62 | + | pull: upstream.number, | |
| 63 | + | // What billing finds the run's requests by, to charge what they cost. | |
| 64 | + | session: upstream.session, | |
| 65 | + | }), | |
| 59 | 66 | ); | |
| 60 | 67 | if (hosted.AI_GATEWAY_TOKEN) headers.set("cf-aig-authorization", `Bearer ${hosted.AI_GATEWAY_TOKEN}`); | |
| 61 | 68 | if (hosted.ANTHROPIC_API_KEY) headers.set("x-api-key", hosted.ANTHROPIC_API_KEY); |
| 557 | 557 | task, | |
| 558 | 558 | model: own ? `${modelName} (${session?.providerName ?? "own provider"})` : modelName, | |
| 559 | 559 | billedTo: own ? "workspace" : "g1t", | |
| 560 | + | session: own ? null : (session?.id ?? null), | |
| 560 | 561 | }); | |
| 561 | 562 | if (!ticket.ok) return ticket; | |
| 562 | 563 | const vars: Record<string, string> = session |