flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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.

syntaqxcommitted Parent1e16bd5Browse files
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