Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

21 files+1157−600/21 viewed
+39−1
8787 assigned the issue. That is why only members of a workspace can put g1t
8888 agents to work on its repositories.
8989
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+
90126 ## Sandbox time
91127
92128 Every sandbox g1t starts for a workspace runs on Cloudflare Containers, and
98134 | | |
99135 | --- | --- |
100136 | 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 |
102138 | What it costs g1t | about $0.0013 a minute (Containers, standard-1) |
103139
140+Both follow what Cloudflare bills; see [How prices are set](#how-prices-are-set).
141+
104142 Deploy builds are not counted here: [Deployments](/guides/deployments/)
105143 charges them by the second on its own plan.
106144
+2−0
165165 </Form>
166166 <nav className="flex items-center gap-0.5">
167167 <HeaderLink to="/explore">Explore</HeaderLink>
168+ <HeaderLink to="/pricing">Pricing</HeaderLink>
168169 <HeaderLink to="https://docs.g1t.sh/">Docs</HeaderLink>
169170 </nav>
170171 <div className="ml-auto flex items-center gap-2">
262263 title: "Product",
263264 links: [
264265 ["Explore repositories", "/explore"],
266+ ["Pricing", "/pricing"],
265267 ["g1t agents", "https://docs.g1t.sh/guides/g1t-agents/"],
266268 ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"],
267269 ["Integrations", "https://docs.g1t.sh/guides/integrations/"],
+1−0
1313 route("new", "routes/new.tsx"),
1414 route("settings", "routes/settings.tsx"),
1515 route("explore", "routes/explore.tsx", { id: "explore" }),
16+ route("pricing", "routes/pricing.tsx"),
1617 route("search", "routes/explore.tsx", { id: "search" }),
1718 route("workspaces/new", "routes/workspace/new.tsx"),
1819 // A workspace's own pages sit under `-`, which no repository can be named.
+185−0
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+}
+69−0
181181 /// The runner, which is TypeScript, sends it as `billedTo`.
182182 #[serde(default = "g1t", alias = "billedTo")]
183183 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>,
184188 }
185189
186190 #[derive(Clone, Debug, Serialize, Deserialize)]
401405 pub workspace: String,
402406 }
403407
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+
404422 /// `set_spend_limit`: the owner's own monthly ceiling, under g1t's; None
405423 /// removes it. Owners only. Returns `Outcome<Limit>`.
406424 #[derive(Debug, Serialize, Deserialize)]
411429 pub spend_limit_micros: Option<i64>,
412430 }
413431
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+
414483 /// What a feature's plan costs and includes.
415484 #[derive(Clone, Debug, Serialize, Deserialize)]
416485 #[serde(rename_all = "camelCase")]
+8−0
353353 pub provider_name: Option<String>,
354354 /// The model to use instead of g1t's choice, if the connection names one.
355355 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,
356361 }
357362
358363 /// What the model proxy needs to forward one run's requests.
376381 pub repo: String,
377382 pub number: u32,
378383 pub task: String,
384+ /// The session's id; see `ModelSession::id`.
385+ #[serde(default)]
386+ pub session: String,
379387 /// For `endpoint`: where to send requests.
380388 pub base_url: Option<String>,
381389 /// For `anthropic` and `endpoint`: the workspace's key.
+34−0
9393 message: string | null;
9494 };
9595
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+
96121 export type Trial = {
97122 open: boolean;
98123 usedMicros: number;
205230 repo?: string | null;
206231 reference: string;
207232 }): 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>;
208240 /** A workspace's limit, for its members. */
209241 limit(workspace: string, viewer: Viewer): Promise<Result<Limit>>;
210242 /** The same, for the services that enforce it. */
231263 model: string;
232264 /** `workspace` when the run uses the workspace's own model provider. */
233265 billedTo?: "g1t" | "workspace";
266+ /** The model session's id, so the run can be settled at AI Gateway's price. */
267+ session?: string | null;
234268 }): Promise<Result<RunTicket | null>>;
235269 }
236270
+2−0
202202 recordSandbox: (usage) => call("record_sandbox", usage),
203203 limit: (workspace, viewer) => call("limit", { workspace, viewer }),
204204 checkLimit: (workspace) => call("check_limit", { workspace }),
205+ prices: () => call("prices", {}),
206+ notePending: (workspace, source, costMicros) => call("note_pending", { workspace, source, costMicros }),
205207 setSpendLimit: (actor, workspace, spendLimitMicros) =>
206208 call("set_spend_limit", { actor, workspace, spendLimitMicros }),
207209 };
+4−0
122122 billedTo: "g1t" | "workspace";
123123 providerName: string | null;
124124 model: string | null;
125+ /** Names the run in AI Gateway's logs (`metadata.session`), for billing. */
126+ id: string;
125127 };
126128
127129 export type ModelUpstream = {
138140 repo: string;
139141 number: number;
140142 task: string;
143+ /** The session's id; see `ModelSession.id`. */
144+ session: string;
141145 baseUrl: string | null;
142146 apiKey: string | null;
143147 authHeader: string | null;
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "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",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+58−0
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+);
+15−0
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+);
+555−0
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+}
+82−50
1717 //! the methods and their arguments.
1818
1919 mod features;
20+mod keeper;
2021 mod limits;
2122 mod stripe;
2223
2728 use serde::Deserialize;
2829 use sha2::{Digest, Sha256};
2930 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};
3132
3233 use stripe::Stripe;
3334
597598 let token = hex::encode(bytes);
598599 self.db
599600 .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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
602603 )
603604 .bind(&[
604605 run_id.as_str().into(),
610611 hash(&token).into(),
611612 rfc3339(now).into(),
612613 if a.billed_to == "workspace" { "workspace" } else { "g1t" }.into(),
614+ optional(a.session.as_deref().filter(|_| a.billed_to != "workspace")),
613615 ])?
614616 .run()
615617 .await?;
719721 .await?
720722 .map_or(seconds, |used| used.seconds);
721723 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 };
723730 let mut description = format!("{}: {} of sandbox time", a.description, duration(seconds));
724731 if billable < seconds {
725732 description.push_str(if billable == 0 {
746753 (-(charge as f64)).into(),
747754 description.as_str().into(),
748755 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(),
750757 a.reference.as_str().into(),
751758 timestamp.as_str().into(),
752759 ])?,
793800 )
794801 }
795802
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+
796870 #[event(fetch)]
797871 async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
798872 let Some(method) = rpc_method(&request) else {
799873 return Response::error("Not found", 404);
800874 };
801875 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)?;
847877 match method.as_str() {
848878 "status" => reply(&billing.status()),
849879 "account" => reply(&billing.account(args(body)?).await?),
865895 "limit" => reply(&billing.limit(args(body)?).await?),
866896 "check_limit" => reply(&billing.check_limit(args(body)?).await?),
867897 "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?),
868900 _ => Response::error("Unknown method", 404),
869901 }
870902 }
+31−1
2121 //! exemption from the ceiling. Test-mode payments are not money, so they
2222 //! do not raise trust.
2323
24−use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, LimitState, SetSpendLimitArgs, Trust};
24+use g1t_contracts::billing::{CheckLimitArgs, Limit, LimitArgs, NotePendingArgs, LimitState, SetSpendLimitArgs, Trust};
2525 use g1t_contracts::time::rfc3339;
2626 use g1t_contracts::{FailureCode, Outcome, Role};
2727 use g1t_kit::now_ms;
124124 .first::<Month>(None)
125125 .await?;
126126 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;
127137 // Test-mode payments are not money: they pay nothing off.
128138 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
129139 let exposure = (used - if live { paid_month } else { 0 }).max(0);
216226 Ok(Outcome::Ok(self.limit_of(&workspace).await?))
217227 }
218228
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+
219249 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
220250 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
221251 }
+8−1
4747 "TRIAL_UNTIL": "2026-10-23T06:59:59Z",
4848 // The Deployments plan's monthly price, in cents. Turning the
4949 // 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"
5156 },
57+ // Settling runs every 15 minutes; checking costs daily (keeper::DAILY).
58+ "triggers": { "crons": ["*/15 * * * *", "17 4 * * *"] },
5259 // Secret: STRIPE_SECRET_KEY. Without it nothing is charged and the
5360 // runner decides who may start agents some other way.
5461 "observability": { "enabled": true }
+43−4
823823
824824 /** Each build is charged by the second at the container price plus the margin. */
825825 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);
827828 if (cost <= 0) return;
828829 const what =
829830 row.kind === "preview"
846847 .run();
847848 }
848849
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+
849868 /** Remembers the most apps the workspace had up at once this month. */
850869 private async notePeak(workspace: string): Promise<void> {
851870 await this.db
10611080 }
10621081 }
10631082 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+ }
10641102 }
10651103
10661104 /** Previews no one has visited in their project's idle days. */
10821120 .bind(month())
10831121 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_apps: number }>();
10841122 const a = DEPLOYMENTS_ALLOWANCE;
1123+ const costs = await this.costs();
10851124 for (const meter of due.results) {
10861125 const extraRequests = Math.max(0, meter.requests - a.requests);
10871126 const extraCpu = Math.max(0, meter.cpu_ms - a.cpuMs);
10881127 const extraApps = Math.max(0, meter.peak_apps - a.apps);
10891128 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,
10931132 );
10941133 if (cost > 0) {
10951134 const parts = [
+9−0
104104 issue: Option<String>,
105105 }
106106
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+
107112 #[derive(Deserialize)]
108113 struct SessionRow {
114+ token_hash: String,
109115 workspace: String,
110116 connection_id: Option<String>,
111117 repo: String,
12191225 ])?,
12201226 ])
12211227 .await?;
1228+ let id = session_id(&crypto::sha256_hex(&token));
12221229 Ok(Outcome::Ok(ModelSession {
12231230 token,
12241231 billed_to: if connection.is_some() { "workspace" } else { "g1t" }.to_owned(),
12251232 provider_name: connection.map(|row| row.name.clone()),
12261233 model,
1234+ id,
12271235 }))
12281236 }
12291237
12471255 repo: session.repo,
12481256 number: session.number,
12491257 task: session.task,
1258+ session: session_id(&session.token_hash),
12501259 base_url: None,
12511260 api_key: None,
12521261 auth_header: None,
+2−1
55
66 import { presentedToken, upstreamRequest } from "./route.ts";
77
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" };
99 const hosted = { AI_GATEWAY_ID: "g1t", CLOUDFLARE_ACCOUNT_ID: "acct", AI_GATEWAY_TOKEN: "gw-token" };
1010
1111 function incoming(): Headers {
3535 workspace: "acme",
3636 repo: "acme/web",
3737 pull: 7,
38+ session: "ms_abc",
3839 });
3940 });
4041
+8−1
5555 // be read per kind of work, workspace, repository and pull request.
5656 headers.set(
5757 "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+ }),
5966 );
6067 if (hosted.AI_GATEWAY_TOKEN) headers.set("cf-aig-authorization", `Bearer ${hosted.AI_GATEWAY_TOKEN}`);
6168 if (hosted.ANTHROPIC_API_KEY) headers.set("x-api-key", hosted.ANTHROPIC_API_KEY);
+1−0
557557 task,
558558 model: own ? `${modelName} (${session?.providerName ?? "own provider"})` : modelName,
559559 billedTo: own ? "workspace" : "g1t",
560+ session: own ? null : (session?.id ?? null),
560561 });
561562 if (!ticket.ok) return ticket;
562563 const vars: Record<string, string> = session