Skip to content
1,131 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Prices keep themselves current with what g1t pays1//! 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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily19//! the change is recorded where anyone can see it. A measurement goes
20//! through `pricing` as a proposal: a small move is applied on its own (a
21//! rise only after customers have had notice), a large or suspect one
22//! waits for staff in sudo, so one odd day of data cannot reprice
23//! everything.
Prices keep themselves current with what g1t pays24
25use g1t_contracts::billing::{EntryKind, MICROS_PER_DOLLAR, Price, PriceBook, PriceChange};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily26
Prices keep themselves current with what g1t pays27use g1t_contracts::time::rfc3339;
28use g1t_kit::now_ms;
29use serde::Deserialize;
30use serde_json::{Value, json};
31use worker::{Env, Fetch, Headers, Method, Request, RequestInit, Result};
32
Merge branch 'worktree-agent-a633ac0f7f66d419d'33use crate::{Billing, RunRow};
Prices keep themselves current with what g1t pays34
35/// The cron that also checks costs against Cloudflare's bill.
36pub(crate) const DAILY: &str = "17 4 * * *";
Merge platform pause and the hourly usage watcher: staff can pause compute, schedules, indexing or renders for everyone, the watcher emails on a breach and is never blind quietly, and the models proxy holds each run to its cap (billing 0051, integrations 0006)37/// The quarter-hourly tick: settling, and once an hour the platform watch.
38pub(crate) const QUARTER_HOURLY: &str = "*/15 * * * *";
Prices keep themselves current with what g1t pays39
40/// A run is settled once its logs have had time to land.
41const SETTLE_AFTER_MS: u64 = 5 * 60 * 1000;
42/// A run with no gateway logs after this is left as reported.
43const GIVE_UP_AFTER_MS: u64 = 3 * 60 * 60 * 1000;
44/// A run never finished after this died without reporting.
45const ABANDONED_AFTER_MS: u64 = 3 * 60 * 60 * 1000;
46
47/// Where the keeper reads what g1t pays.
48pub(crate) struct Keeper {
49 /// `CLOUDFLARE_USAGE_TOKEN`: Billing, Account Analytics and AI Gateway,
50 /// read only.
51 token: Option<String>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily52 /// What reads the bill for `costs`: `CLOUDFLARE_BILLING_TOKEN`
53 /// (Account: Billing Read and Account Analytics Read), or the usage
54 /// token, which has both.
55 billing_token: Option<String>,
Prices keep themselves current with what g1t pays56 account: String,
57 gateway: String,
58}
59
60impl Keeper {
61 pub(crate) fn from_env(env: &Env) -> Self {
62 let var = |name: &str| env.var(name).map(|v| v.to_string()).unwrap_or_default();
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily63 let secret = |name: &str| env.secret(name).ok().map(|v| v.to_string()).filter(|v| !v.is_empty());
64 let token = secret("CLOUDFLARE_USAGE_TOKEN");
Prices keep themselves current with what g1t pays65 Keeper {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily66 billing_token: secret("CLOUDFLARE_BILLING_TOKEN").or_else(|| token.clone()),
67 token,
Prices keep themselves current with what g1t pays68 account: var("CLOUDFLARE_ACCOUNT_ID"),
69 gateway: var("AI_GATEWAY_ID"),
70 }
71 }
72
73 async fn send(&self, method: Method, url: &str, body: Option<Value>) -> Result<Value> {
74 let Some(token) = &self.token else {
75 return Err(worker::Error::RustError("no CLOUDFLARE_USAGE_TOKEN".into()));
76 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily77 send_with(token, method, url, body).await
78 }
79
80 /// Whether Cloudflare's bill can be read.
81 pub(crate) fn can_read_bill(&self) -> bool {
82 self.billing_token.is_some() && !self.account.is_empty()
83 }
84
85 fn billing_token(&self) -> Result<&str> {
86 self.billing_token
87 .as_deref()
88 .ok_or_else(|| worker::Error::RustError("no CLOUDFLARE_BILLING_TOKEN or CLOUDFLARE_USAGE_TOKEN".into()))
89 }
90
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)91 /// Billable usage from `from` to `to` (dates), every page of it, as one
92 /// answer (`result` holds all the rows), and how many pages it took.
93 /// An answer that says it failed is returned as it is.
94 pub(crate) async fn billable_usage_pages(&self, from: &str, to: &str) -> Result<(Value, u32)> {
95 usage_pages(self.billing_token()?, &self.api(&format!("/billable-usage?from={from}&to={to}"))).await
Prices keep themselves current with what g1t pays96 }
97
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing98 /// The account's subscriptions (Workers Paid, add-ons), as Cloudflare
99 /// answers them. Needs Account: Billing Read.
100 pub(crate) async fn subscriptions_body(&self) -> Result<Value> {
101 send_with(self.billing_token()?, Method::Get, &self.api("/subscriptions"), None).await
102 }
103
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily104 /// A GraphQL Analytics query, as Cloudflare answers it, errors and all.
105 pub(crate) async fn graphql(&self, body: Value) -> Result<Value> {
106 send_with(self.billing_token()?, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body)).await
107 }
108
109 pub(crate) fn account(&self) -> &str {
110 &self.account
111 }
112
Prices keep themselves current with what g1t pays113 fn api(&self, path: &str) -> String {
114 format!("https://api.cloudflare.com/client/v4/accounts/{}{path}", self.account)
115 }
116
Merge branch 'worktree-agent-a633ac0f7f66d419d'117 /// AI Gateway's id, empty when there is none.
118 pub(crate) fn gateway(&self) -> &str {
119 &self.gateway
120 }
121
Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said122 /// A GraphQL query over AI Gateway's analytics: with the keeper's token
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises123 /// (AI Gateway Read) first, and on failure with the bill's. A token
124 /// without Account Analytics Read gets an error ("caller does not hold
125 /// any of the required permissions for this dataset"); when the answer
126 /// has no rows, `gateway_visible` tells a token that cannot see the
127 /// gateway from a gateway nothing went through.
Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said128 pub(crate) async fn gateway_graphql(&self, body: Value) -> Result<Value> {
129 let Some(token) = &self.token else {
130 return self.graphql(body).await;
131 };
132 match send_with(token, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body.clone())).await {
Merge branch 'worktree-agent-a633ac0f7f66d419d'133 Ok(answer) => Ok(answer),
Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said134 Err(error) => match &self.billing_token {
135 Some(billing) if billing != token => self.graphql(body).await,
Merge branch 'worktree-agent-a633ac0f7f66d419d'136 _ => Err(error),
137 },
138 }
139 }
140
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises141 /// Whether the token AI Gateway's analytics are read with can see the
142 /// gateway: the REST API answers 403 for a token without AI Gateway
143 /// Read and 404 for a gateway id that is not there. None when the
144 /// answer says neither (Cloudflare down, no token).
145 pub(crate) async fn gateway_visible(&self) -> Option<bool> {
146 let token = self.token.as_deref().or(self.billing_token.as_deref())?;
147 match send_with(token, Method::Get, &self.api(&format!("/ai-gateway/gateways/{}", self.gateway)), None).await {
148 Ok(_) => Some(true),
149 Err(error) => refused(&error.to_string()).then_some(false),
150 }
151 }
152
Merge branch 'worktree-agent-a633ac0f7f66d419d'153 /// What AI Gateway priced a session's requests at, and how many there
154 /// were, with the requests it had no price for.
155 async fn session_cost(&self, session: &str) -> Result<SessionCost> {
156 let mut total = SessionCost { complete: true, ..SessionCost::default() };
157 for page in 1..=MAX_LOG_PAGES {
The keeper reads Cloudflare as it really answers158 // The filter goes as URL-encoded JSON; the bracket form is
159 // ignored, and would sum every log there is. Session ids are
160 // [a-z0-9_], which need no escaping inside it.
161 let filter = format!(
162 "%5B%7B%22key%22%3A%22metadata.value%22%2C%22operator%22%3A%22eq%22%2C%22value%22%3A%5B%22{session}%22%5D%7D%5D"
163 );
Prices keep themselves current with what g1t pays164 let url = self.api(&format!(
The keeper reads Cloudflare as it really answers165 "/ai-gateway/gateways/{}/logs?per_page=50&page={page}&filters={filter}",
Prices keep themselves current with what g1t pays166 self.gateway
167 ));
168 let body = self.send(Method::Get, &url, None).await?;
169 let logs = body["result"].as_array().cloned().unwrap_or_default();
170 for log in &logs {
Merge branch 'worktree-agent-a633ac0f7f66d419d'171 total.add(log);
Prices keep themselves current with what g1t pays172 }
173 if logs.len() < 50 {
Merge branch 'worktree-agent-a633ac0f7f66d419d'174 return Ok(total);
Prices keep themselves current with what g1t pays175 }
176 }
Merge branch 'worktree-agent-a633ac0f7f66d419d'177 // More logs than were read: what was read is less than the run.
178 total.complete = false;
179 Ok(total)
Prices keep themselves current with what g1t pays180 }
181
The keeper reads Cloudflare as it really answers182 /// The account's billable usage, one row per service per day, as
183 /// Cloudflare reports it.
Prices keep themselves current with what g1t pays184 async fn billable_usage(&self, from: &str, to: &str) -> Result<Vec<UsageRow>> {
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)185 let Some(token) = &self.token else {
186 return Err(worker::Error::RustError("no CLOUDFLARE_USAGE_TOKEN".into()));
187 };
188 let (body, _) = usage_pages(token, &self.api(&format!("/billable-usage?from={from}&to={to}"))).await?;
Prices keep themselves current with what g1t pays189 let rows = body["result"].as_array().cloned().unwrap_or_default();
190 Ok(rows.iter().filter_map(UsageRow::from_value).collect())
191 }
192
The keeper reads Cloudflare as it really answers193 /// What g1t's containers used from `since` to `until` (dates), as
194 /// Cloudflare bills it: memory in byte-seconds, and CPU seconds.
195 async fn container_usage(&self, since: &str, until: &str) -> Result<ContainerUsage> {
196 let query = "query ($account: String!, $since: Date!, $until: Date!) {
Prices keep themselves current with what g1t pays197 viewer { accounts(filter: { accountTag: $account }) {
The keeper reads Cloudflare as it really answers198 containersUsageAdaptiveGroups(limit: 1000, filter: { date_geq: $since, date_leq: $until }) {
199 sum { cpuTimeSec allocatedMemory }
Prices keep themselves current with what g1t pays200 }
201 } }
202 }";
203 let body = self
204 .send(
205 Method::Post,
206 "https://api.cloudflare.com/client/v4/graphql",
207 Some(json!({ "query": query, "variables": { "account": self.account, "since": since, "until": until } })),
208 )
209 .await?;
The keeper reads Cloudflare as it really answers210 let groups = body["data"]["viewer"]["accounts"][0]["containersUsageAdaptiveGroups"]
Prices keep themselves current with what g1t pays211 .as_array()
212 .cloned()
213 .unwrap_or_default();
The keeper reads Cloudflare as it really answers214 Ok(groups.iter().fold(ContainerUsage::default(), |total, g| ContainerUsage {
215 cpu_seconds: total.cpu_seconds + g["sum"]["cpuTimeSec"].as_f64().unwrap_or(0.0),
216 memory_byte_seconds: total.memory_byte_seconds + g["sum"]["allocatedMemory"].as_f64().unwrap_or(0.0),
217 }))
Prices keep themselves current with what g1t pays218 }
219}
220
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises221/// Whether an error from `send_with` is Cloudflare saying no to the token
222/// (401, 403) or that there is no such thing for it (404), rather than
223/// failing.
224pub(crate) fn refused(error: &str) -> bool {
225 ["Cloudflare answered 401", "Cloudflare answered 403", "Cloudflare answered 404"].iter().any(|s| error.contains(s))
226}
227
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily228/// A request to Cloudflare's API with a bearer token; anything but 200 is
229/// an error with what Cloudflare said.
Merge Cloudflare's usage over its billing cycle: every page read, included amounts once a cycle, a projection, test-mode charges never money in (billing 0052)230/// The most pages of billable usage read in one go: far more than a few
231/// months of g1t's meters.
232const MAX_USAGE_PAGES: u32 = 50;
233
234/// Every page of a billable-usage answer, its rows together. Before
235/// 2026-10-09 only the first page was read.
236async fn usage_pages(token: &str, url: &str) -> Result<(Value, u32)> {
237 let mut rows: Vec<Value> = Vec::new();
238 let mut pages = 0;
239 let mut next = url.to_owned();
240 loop {
241 let body = send_with(token, Method::Get, &next, None).await?;
242 if body["success"] == Value::Bool(false) {
243 return Ok((body, pages + 1));
244 }
245 pages += 1;
246 rows.extend(body["result"].as_array().cloned().unwrap_or_default());
247 match crate::costs::next_page(&body, pages).filter(|_| pages < MAX_USAGE_PAGES) {
248 Some(query) => next = format!("{url}&{query}"),
249 None => break,
250 }
251 }
252 Ok((json!({ "success": true, "result": rows }), pages))
253}
254
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily255async fn send_with(token: &str, method: Method, url: &str, body: Option<Value>) -> Result<Value> {
256 let headers = Headers::new();
257 headers.set("authorization", &format!("Bearer {token}"))?;
258 headers.set("content-type", "application/json")?;
259 let mut init = RequestInit::new();
260 init.with_method(method).with_headers(headers);
261 if let Some(body) = body {
262 init.with_body(Some(body.to_string().into()));
263 }
264 let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
265 let status = response.status_code();
266 let value: Value = response.json().await.unwrap_or(Value::Null);
267 if status != 200 {
268 return Err(worker::Error::RustError(format!("Cloudflare answered {status}: {value}")));
269 }
270 Ok(value)
271}
272
The keeper reads Cloudflare as it really answers273#[derive(Debug, Default, Clone, Copy)]
274pub(crate) struct ContainerUsage {
275 cpu_seconds: f64,
276 memory_byte_seconds: f64,
277}
278
279/// g1t's sandboxes: Containers' standard-1, half a vCPU, 4 GiB, 8 GB disk.
280const SANDBOX_GIB: f64 = 4.0;
281const SANDBOX_DISK_GB: f64 = 8.0;
282const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look283/// The Durable Object behind each container is billed for as long as the
284/// container runs, at 128 MB.
285const SANDBOX_DO_GB: f64 = 0.125;
The keeper reads Cloudflare as it really answers286
287/// Cloudflare's published Containers rates, in dollars, used for any rate
288/// the bill does not show yet (while usage is inside the included amount).
289const LIST_MEMORY_GIB_SECOND: f64 = 0.000_002_5;
290const LIST_DISK_GB_SECOND: f64 = 0.000_000_07;
291const LIST_VCPU_SECOND: f64 = 0.000_02;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292/// Durable Objects duration: $12.50 per million GB-seconds.
293const LIST_DO_GB_SECOND: f64 = 0.000_012_5;
The keeper reads Cloudflare as it really answers294
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295/// What one second of a sandbox costs whatever it does, in millionths of a
296/// dollar: its memory and disk, and the Durable Object behind it, for the
297/// whole second. CPU is billed only while busy, on top.
298pub(crate) fn sandbox_base_micros(memory: f64, disk: f64, durable_object: f64) -> f64 {
299 (SANDBOX_GIB * memory + SANDBOX_DISK_GB * disk + SANDBOX_DO_GB * durable_object) * MICROS_PER_DOLLAR as f64
300}
301
302/// What one second of a sandbox costs on average, in millionths of a
303/// dollar: its base, and the CPU sandboxes actually use per second of
304/// running. Runs that report their own CPU are priced on it instead (see
305/// `run_cost`).
306pub(crate) fn sandbox_second_micros(usage: ContainerUsage, memory: f64, disk: f64, vcpu: f64, durable_object: f64) -> Option<f64> {
The keeper reads Cloudflare as it really answers307 let instance_seconds = usage.memory_byte_seconds / (SANDBOX_GIB * GIB);
308 if instance_seconds < 3600.0 {
309 return None;
310 }
311 let cpu_share = usage.cpu_seconds / instance_seconds;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312 Some(sandbox_base_micros(memory, disk, durable_object) + cpu_share * vcpu * MICROS_PER_DOLLAR as f64)
313}
314
Fast pages, required checks on the branch, self-hosted runners, honest incidents315/// How much more a second of a larger machine's memory and disk (and the
316/// Durable Object behind it) costs than the standard sandbox's, at
317/// Cloudflare's list rates: 1 for the standard machine.
318pub(crate) fn base_scale(memory_gib: f64, disk_gb: f64) -> f64 {
319 let base = |memory: f64, disk: f64| memory * LIST_MEMORY_GIB_SECOND + disk * LIST_DISK_GB_SECOND + SANDBOX_DO_GB * LIST_DO_GB_SECOND;
320 base(memory_gib, disk_gb) / base(SANDBOX_GIB, SANDBOX_DISK_GB)
321}
322
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look323/// What a run that reported its own CPU cost g1t: its base for every
324/// second, and its vCPU-seconds at the vCPU rate.
325pub(crate) fn run_cost(seconds: i64, cpu_seconds: f64, base_per_second: f64, per_vcpu_second: f64) -> f64 {
326 seconds.max(0) as f64 * base_per_second + cpu_seconds.max(0.0) * per_vcpu_second
The keeper reads Cloudflare as it really answers327}
328
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too329/// A unit's rate from the bill: the median, over the days with a list
330/// cost, of list cost over quantity. Never what was billed: that is net of
331/// the included amounts (nothing while a cycle is inside them, part of a
332/// day's usage on the day it passes one), so over all of the quantity it
333/// reads as a lower price when nothing changed. None without a list cost;
334/// the published rates stand in.
The keeper reads Cloudflare as it really answers335pub(crate) fn billed_rate(rows: &[&UsageRow]) -> Option<f64> {
336 let mut rates: Vec<f64> = rows
337 .iter()
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too338 .filter(|r| r.list_cost > 0.0 && r.quantity > 0.0)
339 .map(|r| r.list_cost / r.quantity)
The keeper reads Cloudflare as it really answers340 .collect();
341 if rates.is_empty() {
342 return None;
343 }
344 rates.sort_by(f64::total_cmp);
345 Some(rates[rates.len() / 2])
346}
347
Prices keep themselves current with what g1t pays348/// One line of Cloudflare's billable usage.
349#[derive(Debug, Clone)]
350pub(crate) struct UsageRow {
351 period_start: String,
352 period_end: String,
353 service: String,
354 unit: String,
355 quantity: f64,
356 cost: f64,
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too357 /// The quantity at list price, before the included amounts.
358 list_cost: f64,
Prices keep themselves current with what g1t pays359}
360
361impl UsageRow {
362 /// Read leniently: the API is new, and its field names are FOCUS's.
363 fn from_value(row: &Value) -> Option<Self> {
364 let text = |keys: &[&str]| keys.iter().find_map(|k| row[*k].as_str()).unwrap_or_default().to_owned();
365 let number = |keys: &[&str]| {
366 keys.iter()
367 .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.parse().ok())))
368 .unwrap_or(0.0)
369 };
370 let service = text(&["ServiceName", "service_name", "service"]);
371 if service.is_empty() {
372 return None;
373 }
374 let family = text(&["ServiceFamilyName", "service_family_name"]);
375 Some(UsageRow {
376 period_start: text(&["ChargePeriodStart", "charge_period_start"]),
377 period_end: text(&["ChargePeriodEnd", "charge_period_end"]),
378 service: if family.is_empty() { service } else { format!("{family} / {service}") },
The keeper reads Cloudflare as it really answers379 unit: text(&["PricingUnit", "ConsumedUnit", "consumed_unit"]),
Prices keep themselves current with what g1t pays380 quantity: number(&["PricingQuantity", "ConsumedQuantity", "pricing_quantity"]),
The keeper reads Cloudflare as it really answers381 // What g1t pays; list price if nothing was contracted.
382 cost: Some(number(&["ContractedCost", "BilledCost", "contracted_cost"]))
383 .filter(|cost| *cost > 0.0)
384 .unwrap_or_else(|| number(&["ListCost", "list_cost"])),
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too385 list_cost: number(&["ListCost", "list_cost"]),
Prices keep themselves current with what g1t pays386 })
387 }
388}
389
390#[derive(Deserialize)]
391struct PriceRow {
392 meter: String,
393 title: String,
394 unit: String,
395 cost_micros: f64,
396 markup_percent: u32,
397 source: String,
398 checked_at: Option<String>,
399 updated_at: String,
400}
401
402#[derive(Deserialize)]
403struct ChangeRow {
404 meter: String,
405 old_cost_micros: f64,
406 new_cost_micros: f64,
407 markup_percent: u32,
Prices are what g1t pays plus 20%, from the first second408 old_markup_percent: Option<u32>,
Prices keep themselves current with what g1t pays409 reason: String,
410 created_at: String,
411}
412
413#[derive(Deserialize)]
414struct Unsettled {
415 id: String,
416 workspace: String,
417 repo: String,
418 number: u32,
419 task: String,
420 model: String,
421 token_hash: String,
422 billed_to: Option<String>,
423 session_id: String,
424 created_at: String,
425 finished_at: Option<String>,
426}
427
428#[derive(Deserialize)]
429struct Charged {
430 cost_micros: Option<i64>,
431 description: String,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put432 amount_micros: i64,
433}
434
435/// What a settled run's correction comes to, from the charges at the
436/// reported and the gateway's cost and what the workspace was charged at
437/// first. A charge up is drawn down like any charge; a charge down is
438/// given back only up to what the workspace paid, since what a credit or
439/// a pool paid was never the workspace's money.
440pub(crate) fn correction(reported_charge: i64, gateway_charge: i64, first_charged: i64) -> i64 {
441 let delta = gateway_charge - reported_charge;
442 if delta >= 0 { delta } else { delta.max(-first_charged.max(0)) }
Prices keep themselves current with what g1t pays443}
444
Merge branch 'worktree-agent-a633ac0f7f66d419d'445/// A session's logs are read 50 at a time, up to this many pages.
446const MAX_LOG_PAGES: u32 = 40;
447
448/// What AI Gateway's logs say a session cost.
449#[derive(Clone, Debug, Default, PartialEq)]
450pub(crate) struct SessionCost {
451 /// What the gateway priced the requests at, in dollars.
452 pub cost_usd: f64,
453 pub requests: u32,
454 /// Requests that used tokens but that the gateway put no price on: a
455 /// model it has no price for. Their cost is not in `cost_usd`.
456 pub unpriced: u32,
457 /// The models of those, for the statement and the drift.
458 pub unpriced_models: Vec<String>,
459 /// False when there were more logs than were read.
460 pub complete: bool,
461}
462
463impl SessionCost {
464 /// Adds one log, read leniently: `cost` in dollars, `tokens_in` and
465 /// `tokens_out`, `cached` for an answer from the gateway's own cache
466 /// (which costs nothing).
467 pub(crate) fn add(&mut self, log: &Value) {
468 self.requests += 1;
469 let number = |key: &str| log[key].as_f64().or_else(|| log[key].as_str().and_then(|s| s.parse().ok()));
470 let cost = number("cost").filter(|c| c.is_finite() && *c > 0.0);
471 let tokens = number("tokens_in").unwrap_or(0.0) + number("tokens_out").unwrap_or(0.0);
472 let cached = log["cached"].as_bool().unwrap_or(false);
473 match cost {
474 Some(cost) => self.cost_usd += cost,
475 None if tokens > 0.0 && !cached => {
476 self.unpriced += 1;
477 let model = log["model"].as_str().unwrap_or("an unnamed model").to_owned();
478 if !self.unpriced_models.contains(&model) {
479 self.unpriced_models.push(model);
480 }
481 }
482 None => {}
483 }
484 }
485
486 /// Whether the gateway's figure is the whole of what the run cost.
487 pub(crate) fn whole(&self) -> bool {
488 self.complete && self.unpriced == 0
489 }
490}
491
492/// The cost a run is settled at, in millionths: the gateway's figure when
493/// it priced every request; otherwise (a model it has no price for, or
494/// more logs than were read) never less than the sandbox reported, since
495/// the gateway's sum is then short of what the provider bills. With a
496/// reason for the statement and the drift when it is not the gateway's
497/// figure alone.
498pub(crate) fn settled_cost(reported_micros: i64, gateway: &SessionCost) -> (i64, Option<String>) {
499 // Not held to MAX_RUN_COST_USD: the gateway's figure is trusted.
500 let priced = if gateway.cost_usd.is_finite() { (gateway.cost_usd.max(0.0) * MICROS_PER_DOLLAR as f64).ceil() as i64 } else { 0 };
501 if gateway.whole() {
502 return (priced, None);
503 }
504 let mut why = Vec::new();
505 if gateway.unpriced > 0 {
506 why.push(format!(
507 "AI Gateway has no price for {} of its {} requests ({})",
508 gateway.unpriced,
509 gateway.requests,
510 gateway.unpriced_models.join(", ")
511 ));
512 }
513 if !gateway.complete {
514 why.push(format!("more than {} of its requests were logged", gateway.requests));
515 }
516 (priced.max(reported_micros.max(0)), Some(why.join("; ")))
517}
518
Prices keep themselves current with what g1t pays519fn ms(timestamp: &str) -> u64 {
520 // RFC 3339 in UTC, as g1t writes them.
521 worker::js_sys::Date::parse(timestamp) as u64
522}
523
524impl Billing {
525 pub(crate) async fn prices(&self) -> Result<PriceBook> {
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index526 // Three reads at once; the plans are priced from the rows already
527 // read, not one query per meter (status.g1t.sh times this call).
528 let (prices, changes, coming) = futures_util::future::join3(
529 async { self.db.prepare("SELECT * FROM prices ORDER BY rowid").all().await?.results::<PriceRow>() },
530 async {
531 self.db
532 .prepare("SELECT * FROM price_changes ORDER BY created_at DESC LIMIT 20")
533 .all()
534 .await?
535 .results::<ChangeRow>()
536 },
537 // Changes still to come first, so a rise is seen before it is charged.
538 self.coming_changes(),
539 )
540 .await;
541 let (prices, changes, coming) = (prices?, changes?, coming?);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit542 let model_markup = prices.iter().find(|row| row.meter == "agent_models").map_or(self.margin_percent, |row| row.markup_percent);
Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index543 let book: std::collections::BTreeMap<&str, f64> = prices
544 .iter()
545 .map(|row| (row.meter.as_str(), Price::price_for(row.cost_micros, row.markup_percent)))
546 .collect();
Merge Stripe Tax, the card fee on card payments, and one free workspace per person547 // With the card fee on top of each monthly price, as it is charged.
548 let card_fee = self.card_fee().await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar549 let plans: Vec<_> = g1t_contracts::billing::Feature::ALL
550 .iter()
Merge Stripe Tax, the card fee on card payments, and one free workspace per person551 .map(|feature| {
552 let mut plan = match feature {
553 g1t_contracts::billing::Feature::Security => crate::features::security_plan_at(&book),
554 _ => self.plan_at(&book),
555 };
556 plan.card_fee_cents = crate::tax::fee_for(i64::from(plan.monthly_cents), &card_fee) as u32;
557 plan
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar558 })
559 .collect();
Prices keep themselves current with what g1t pays560 Ok(PriceBook {
561 prices: prices
562 .into_iter()
563 .map(|row| Price {
564 price_micros: Price::price_for(row.cost_micros, row.markup_percent),
565 meter: row.meter,
566 title: row.title,
567 unit: row.unit,
568 cost_micros: row.cost_micros,
569 markup_percent: row.markup_percent,
570 source: row.source,
571 checked_at: row.checked_at,
572 updated_at: row.updated_at,
573 })
574 .collect(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily575 changes: coming
Prices keep themselves current with what g1t pays576 .into_iter()
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily577 .chain(changes.into_iter().map(|row| PriceChange {
Prices keep themselves current with what g1t pays578 meter: row.meter,
579 old_cost_micros: row.old_cost_micros,
580 new_cost_micros: row.new_cost_micros,
581 markup_percent: row.markup_percent,
Prices are what g1t pays plus 20%, from the first second582 old_markup_percent: row.old_markup_percent,
Prices keep themselves current with what g1t pays583 reason: row.reason,
584 created_at: row.created_at,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily585 effective_at: None,
586 }))
Prices keep themselves current with what g1t pays587 .collect(),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit588 // The markup on models' provider price: the price book's
589 // `agent_models` (none from 2026-10-08).
590 model_margin_percent: model_markup,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily591 plans,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put592 free: Some(g1t_contracts::billing::FreeTier {
593 trial_workspace_micros: if self.trials_on { self.plans.trial_workspace_micros } else { 0 },
594 trial_monthly_pool_micros: if self.trials_on { self.plans.trial_monthly_pool_micros } else { 0 },
595 oss_pool_micros: self.plans.oss_pool_micros,
596 oss_repo_micros: self.plans.oss_repo_micros,
597 free_private_storage_bytes: self.plans.free_storage_bytes,
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo598 audit_retention_days: self.plans.free_audit_days,
599 plan_audit_retention_days: self.plans.audit_days,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put600 min_charge_micros: self.plans.min_charge_micros,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look601 git_operations_included: self.plans.git_included,
602 paid_start_ceiling_micros: self.plans.paid_start_micros,
603 overage_forgive_cost_micros: self.plans.forgive_cost_micros,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put604 }),
Prices keep themselves current with what g1t pays605 })
606 }
607
The keeper reads Cloudflare as it really answers608 /// Whether the costs have never been checked against Cloudflare's bill.
609 pub(crate) async fn never_checked(&self) -> Result<bool> {
610 Ok(self
611 .db
612 .prepare("SELECT meter FROM prices WHERE checked_at IS NOT NULL LIMIT 1")
613 .first::<Value>(None)
614 .await?
615 .is_none())
616 }
617
Prices keep themselves current with what g1t pays618 /// A meter's cost and price per unit, from the book.
619 pub(crate) async fn price(&self, meter: &str) -> Result<Option<(f64, f64)>> {
620 #[derive(Deserialize)]
621 struct Row {
622 cost_micros: f64,
623 markup_percent: u32,
624 }
625 Ok(self
626 .db
627 .prepare("SELECT cost_micros, markup_percent FROM prices WHERE meter = ?")
628 .bind(&[meter.into()])?
629 .first::<Row>(None)
630 .await?
631 .map(|row| (row.cost_micros, Price::price_for(row.cost_micros, row.markup_percent))))
632 }
633
634 /// Corrects finished runs to what AI Gateway priced them at, and
635 /// charges runs whose sandbox died before reporting.
636 pub(crate) async fn settle_runs(&self, keeper: &Keeper) -> Result<()> {
637 if keeper.token.is_none() || keeper.gateway.is_empty() {
638 return Ok(());
639 }
640 let now = now_ms();
641 let runs = self
642 .db
643 .prepare(
644 "SELECT id, workspace, repo, number, task, model, token_hash, billed_to, session_id, created_at, finished_at
645 FROM runs
Merge branch 'model-routing'646 WHERE session_id IS NOT NULL AND settled_at IS NULL AND COALESCE(billed_to, 'g1t') = 'g1t'
Prices keep themselves current with what g1t pays647 AND ((finished_at IS NOT NULL AND finished_at < ?1) OR created_at < ?2)
648 ORDER BY created_at LIMIT 10",
649 )
650 .bind(&[rfc3339(now - SETTLE_AFTER_MS).into(), rfc3339(now - ABANDONED_AFTER_MS).into()])?
651 .all()
652 .await?
653 .results::<Unsettled>()?;
654 for run in runs {
Merge branch 'worktree-agent-a633ac0f7f66d419d'655 let gateway = match keeper.session_cost(&run.session_id).await {
Prices keep themselves current with what g1t pays656 Ok(found) => found,
657 Err(error) => {
658 worker::console_error!("could not read gateway logs for {}: {error}", run.id);
659 continue;
660 }
661 };
662 let since = ms(run.finished_at.as_deref().unwrap_or(&run.created_at));
Merge branch 'worktree-agent-a633ac0f7f66d419d'663 if gateway.requests == 0 && now.saturating_sub(since) < GIVE_UP_AFTER_MS {
Prices keep themselves current with what g1t pays664 continue;
665 }
Merge branch 'worktree-agent-a633ac0f7f66d419d'666 self.settle(&run, &gateway).await?;
Prices keep themselves current with what g1t pays667 }
668 Ok(())
669 }
670
Merge branch 'model-routing'671 /// Closes runs on a workspace's own model provider: none is on g1t's
672 /// gateway, so nothing is corrected, but tokens the proxy counted after
673 /// the run reported, or for a sandbox that died before reporting, are
674 /// charged their agent rate now. Needs no gateway token.
675 pub(crate) async fn settle_own_runs(&self) -> Result<()> {
676 #[derive(Deserialize)]
677 struct Own {
678 id: String,
679 workspace: String,
680 repo: String,
681 number: u32,
682 task: String,
683 model: String,
684 token_hash: String,
685 billed_to: Option<String>,
686 }
687 let now = now_ms();
688 let runs = self
689 .db
690 .prepare(
691 "SELECT id, workspace, repo, number, task, model, token_hash, billed_to
692 FROM runs
693 WHERE billed_to = 'workspace' AND session_id IS NOT NULL AND settled_at IS NULL
694 AND ((finished_at IS NOT NULL AND finished_at < ?1) OR created_at < ?2)
695 ORDER BY created_at LIMIT 25",
696 )
697 .bind(&[rfc3339(now - SETTLE_AFTER_MS).into(), rfc3339(now - ABANDONED_AFTER_MS).into()])?
698 .all()
699 .await?
700 .results::<Own>()?;
701 for run in runs {
702 let settled_at = rfc3339(now_ms());
703 let claimed = self
704 .db
705 .prepare(
706 "UPDATE runs SET settled_at = ?1, finished_at = COALESCE(finished_at, ?1)
707 WHERE id = ?2 AND settled_at IS NULL RETURNING id",
708 )
709 .bind(&[settled_at.as_str().into(), run.id.as_str().into()])?
710 .first::<Value>(None)
711 .await?;
712 if claimed.is_none() {
713 continue;
714 }
715 let row = RunRow {
716 workspace: run.workspace,
717 repo: run.repo,
718 number: run.number,
719 task: run.task,
720 model: run.model,
721 token_hash: run.token_hash,
722 billed_to: run.billed_to,
723 };
724 self.charge_agent_rate(&run.id, &row, None).await?;
725 }
726 Ok(())
727 }
728
Merge branch 'worktree-agent-a633ac0f7f66d419d'729 async fn settle(&self, run: &Unsettled, gateway: &SessionCost) -> Result<()> {
730 let requests = gateway.requests;
Prices keep themselves current with what g1t pays731 let row = RunRow {
732 workspace: run.workspace.clone(),
733 repo: run.repo.clone(),
734 number: run.number,
735 task: run.task.clone(),
736 model: run.model.clone(),
737 token_hash: run.token_hash.clone(),
738 billed_to: run.billed_to.clone(),
739 };
740 let charged = self
741 .db
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put742 .prepare("SELECT cost_micros, description, amount_micros FROM ledger WHERE reference = ?")
Prices keep themselves current with what g1t pays743 .bind(&[run.id.as_str().into()])?
744 .first::<Charged>(None)
745 .await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'746 let reported = charged.as_ref().and_then(|c| c.cost_micros).unwrap_or(0);
747 // The gateway's figure; never under what the sandbox reported when
748 // the gateway could not price all of it (see `settled_cost`).
749 let (gateway_micros, short) = settled_cost(reported, gateway);
Billing accounts, terms and enterprises; g1t is no longer free750 let terms = self.terms_of(&run.workspace).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit751 // Models at the price book's markup on the provider's price
752 // (`agent_models`), as `finish_run` charges them.
753 let markup = self.model_markup().await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'754 // A cost's charge on the account's terms, and what a discount gave
Usage, Billing settings and prepaid AI credit; fixes from the UX audit755 // below cost plus the markup (counted as given, see `charged`).
Prices keep themselves current with what g1t pays756 let charge_for = |micros: i64| {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit757 if self.free { (0, 0) } else { terms.discounted(crate::margin_on(micros, markup)) }
Prices keep themselves current with what g1t pays758 };
759 let settled_at = rfc3339(now_ms());
760 // Claim it, so two crons never settle it twice.
761 let claimed = self
762 .db
Merge branch 'worktree-agent-a633ac0f7f66d419d'763 .prepare(
764 "UPDATE runs SET settled_at = ?, gateway_cost_micros = ?, gateway_note = ?, finished_at = COALESCE(finished_at, ?)
765 WHERE id = ? AND settled_at IS NULL RETURNING id",
766 )
Prices keep themselves current with what g1t pays767 .bind(&[
768 settled_at.as_str().into(),
769 (gateway_micros as f64).into(),
Merge branch 'worktree-agent-a633ac0f7f66d419d'770 short.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
Prices keep themselves current with what g1t pays771 settled_at.as_str().into(),
772 run.id.as_str().into(),
773 ])?
774 .first::<Value>(None)
775 .await?;
776 if claimed.is_none() || requests == 0 {
777 return Ok(());
778 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit779 // Tokens counted after the run reported are charged their agent
780 // rate now (ai.rs).
Merge branch 'model-routing'781 self.charge_agent_rate(&run.id, &row, None).await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'782 if let Some(why) = &short {
783 worker::console_warn!("run {} settled at no less than reported: {why}", run.id);
784 }
785 let short_note = short.as_ref().map_or(String::new(), |why| format!(" ({why}; charged at no less than the sandbox reported)"));
Prices keep themselves current with what g1t pays786 let free_note = if self.free { " (free while g1t is being built out)" } else { "" };
787 match charged {
788 // Never reported: charged now, from the gateway's figure.
789 None => {
Merge branch 'worktree-agent-a633ac0f7f66d419d'790 let (charge, discount) = charge_for(gateway_micros);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look791 let eligible = crate::credits::eligible_for(Some(g1t_contracts::billing::ComputeKind::Agent), None);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put792 let drawn = self.draw(&run.workspace, charge, &settled_at[..7], &eligible).await?;
Prices keep themselves current with what g1t pays793 let description = format!(
Merge branch 'worktree-agent-a633ac0f7f66d419d'794 "Work on {}#{}, settled from AI Gateway after the sandbox stopped without reporting{short_note}{free_note}{}",
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put795 run.repo,
796 run.number,
797 drawn.note()
Prices keep themselves current with what g1t pays798 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put799 self.enter(&run.workspace, EntryKind::Usage, -(charge - drawn.total()), &description, &run.id, Some(&row), Some(gateway_micros), None, None)
Prices keep themselves current with what g1t pays800 .await?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put801 self.record_drawn(&run.id, &drawn).await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'802 self.record_discount(&run.id, discount).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays803 self.count_spend(&run.workspace, gateway_micros, charge - drawn.total(), &drawn).await;
Prices keep themselves current with what g1t pays804 }
805 Some(charged) => {
806 let delta = gateway_micros - reported;
807 if delta == 0 {
808 return Ok(());
809 }
Merge branch 'worktree-agent-a633ac0f7f66d419d'810 let ((was, was_given), (now, now_given)) = (charge_for(reported), charge_for(gateway_micros));
811 let change = correction(was, now, -charged.amount_micros);
812 // What the discount gives moves with the charge.
813 let discount = now_given - was_given;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put814 // A charge up is paid for like any other charge.
815 let drawn = if change > 0 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look816 let eligible = crate::credits::eligible_for(Some(g1t_contracts::billing::ComputeKind::Agent), None);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put817 self.draw(&run.workspace, change, &settled_at[..7], &eligible).await?
818 } else {
819 crate::credits::Drawn::default()
820 };
Prices keep themselves current with what g1t pays821 let description = format!(
Merge branch 'worktree-agent-a633ac0f7f66d419d'822 "Correction to “{}”: AI Gateway priced its {requests} model requests at {}, not {}{short_note}{}",
Prices keep themselves current with what g1t pays823 charged.description,
824 crate::features::dollars(gateway_micros),
825 crate::features::dollars(reported),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put826 drawn.note(),
Prices keep themselves current with what g1t pays827 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put828 let reference = format!("{}/settled", run.id);
Prices keep themselves current with what g1t pays829 self.enter(
830 &run.workspace,
831 EntryKind::Usage,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put832 -(change - drawn.total()),
Prices keep themselves current with what g1t pays833 &description,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put834 &reference,
Prices keep themselves current with what g1t pays835 Some(&row),
836 Some(delta),
837 None,
838 None,
839 )
840 .await?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put841 self.record_drawn(&reference, &drawn).await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'842 self.record_discount(&reference, discount).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays843 self.count_spend(&run.workspace, delta, change - drawn.total(), &drawn).await;
Prices keep themselves current with what g1t pays844 }
845 }
846 Ok(())
847 }
848
849 /// Checks each cost against what Cloudflare billed this month, and
850 /// moves the ones that changed.
851 pub(crate) async fn reconcile(&self, keeper: &Keeper) -> Result<()> {
852 if keeper.token.is_none() {
853 return Ok(());
854 }
855 let now = rfc3339(now_ms());
856 let today = &now[..10];
The keeper reads Cloudflare as it really answers857 let since = rfc3339(now_ms() - 30 * 24 * 60 * 60 * 1000);
858 let rows = keeper.billable_usage(&since[..10], today).await?;
Prices keep themselves current with what g1t pays859 for row in &rows {
860 self.db
861 .prepare(
862 "INSERT INTO cloudflare_usage (period_start, period_end, service, unit, quantity, cost_usd, fetched_at)
863 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
864 ON CONFLICT (period_start, service, unit) DO UPDATE SET
865 period_end = ?2, quantity = ?5, cost_usd = ?6, fetched_at = ?7",
866 )
867 .bind(&[
868 row.period_start.as_str().into(),
869 row.period_end.as_str().into(),
870 row.service.as_str().into(),
871 row.unit.as_str().into(),
872 row.quantity.into(),
873 row.cost.into(),
874 now.as_str().into(),
875 ])?
876 .run()
877 .await?;
878 }
879
The keeper reads Cloudflare as it really answers880 let named = |words: &[&str]| -> Vec<&UsageRow> {
Prices keep themselves current with what g1t pays881 rows.iter()
The keeper reads Cloudflare as it really answers882 .filter(|r| {
883 let service = r.service.to_lowercase();
884 words.iter().all(|word| service.contains(word))
885 })
886 .collect()
Prices keep themselves current with what g1t pays887 };
888
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too889 // Containers: each resource at the list cost the bill shows for it
890 // (before the included amounts), or the published rate without one,
The keeper reads Cloudflare as it really answers891 // over how much CPU g1t's sandboxes really use per second.
892 let memory = billed_rate(&named(&["container memory"]));
893 let disk = billed_rate(&named(&["container disk"]));
894 let vcpu = billed_rate(&named(&["container vcpu"]));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look895 let durable_object = billed_rate(&named(&["durable objects", "duration"]));
The keeper reads Cloudflare as it really answers896 let usage = keeper.container_usage(&since[..10], today).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look897 let rates = (
The keeper reads Cloudflare as it really answers898 memory.unwrap_or(LIST_MEMORY_GIB_SECOND),
899 disk.unwrap_or(LIST_DISK_GB_SECOND),
900 vcpu.unwrap_or(LIST_VCPU_SECOND),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look901 durable_object.unwrap_or(LIST_DO_GB_SECOND),
902 );
903 // The parts, for runs that report their own CPU.
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too904 let parts_reason = "Cloudflare's Containers and Durable Objects rates, as listed on the bill or published";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look905 self.measure("sandbox_base_second", sandbox_base_micros(rates.0, rates.1, rates.3), parts_reason).await?;
906 self.measure("sandbox_cpu_second", rates.2 * MICROS_PER_DOLLAR as f64, parts_reason).await?;
907 if let Some(per_second) = sandbox_second_micros(usage, rates.0, rates.1, rates.2, rates.3) {
The keeper reads Cloudflare as it really answers908 let instance_seconds = usage.memory_byte_seconds / (SANDBOX_GIB * GIB);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look909 let billed = [("memory", memory), ("disk", disk), ("vCPU", vcpu), ("Durable Object duration", durable_object)]
The keeper reads Cloudflare as it really answers910 .iter()
911 .filter(|(_, rate)| rate.is_some())
912 .map(|(name, _)| *name)
913 .collect::<Vec<_>>();
914 let reason = format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look915 "Sandboxes used {:.2} vCPU per second over {:.0} hours of Cloudflare Containers in the last 30 days, with the Durable Object behind each; {}",
The keeper reads Cloudflare as it really answers916 usage.cpu_seconds / instance_seconds,
917 instance_seconds / 3600.0,
918 if billed.is_empty() {
919 "rates are Cloudflare's published ones".to_owned()
920 } else {
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too921 format!("{} at the list cost on Cloudflare's bill", billed.join(", "))
The keeper reads Cloudflare as it really answers922 },
923 );
924 for meter in ["sandbox_second", "build_second"] {
925 self.measure(meter, per_second, &reason).await?;
Prices keep themselves current with what g1t pays926 }
927 }
The keeper reads Cloudflare as it really answers928 // Apps run as Workers: per million requests and CPU milliseconds,
929 // once the bill shows them charged.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put930 // Security scans' CPU follows the same Workers CPU rate.
931 let app_meters: [(&str, &[&str], &str); 3] = [
The keeper reads Cloudflare as it really answers932 ("app_requests", &["workers", "requests"], "requests"),
933 ("app_cpu", &["workers cpu"], "CPU ms"),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put934 ("scan_cpu", &["workers cpu"], "CPU ms"),
The keeper reads Cloudflare as it really answers935 ];
936 for (meter, words, unit) in app_meters {
937 if let Some(rate) = billed_rate(&named(words)) {
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too938 let reason = format!("Cloudflare's bill lists Workers {unit} at ${:.2} per million", rate * 1e6);
The keeper reads Cloudflare as it really answers939 self.measure(meter, rate * 1e6 * MICROS_PER_DOLLAR as f64, &reason).await?;
Prices keep themselves current with what g1t pays940 }
941 }
942 self.db
943 .prepare("UPDATE prices SET checked_at = ?")
944 .bind(&[now.as_str().into()])?
945 .run()
946 .await?;
947 Ok(())
948 }
949
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily950 /// Proposes moving a meter's cost to a measurement (see `pricing`):
951 /// applied on its own when small, after notice when a rise; left for
952 /// staff when large or suspect.
Prices keep themselves current with what g1t pays953 async fn measure(&self, meter: &str, measured: f64, reason: &str) -> Result<()> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily954 if let Some(outcome) = self.propose(meter, measured, reason, "keeper").await? {
955 worker::console_log!("{meter}: {outcome}");
Prices keep themselves current with what g1t pays956 }
957 Ok(())
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964
965 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put966 fn a_correction_never_gives_back_what_the_workspace_did_not_pay() {
967 // Up by 2 cents: charged in full (then drawn down like any charge).
968 assert_eq!(correction(100_000, 120_000, 100_000), 20_000);
969 // Down by 2 cents, all of it paid by the workspace: given back.
970 assert_eq!(correction(120_000, 100_000, 120_000), -20_000);
971 // Down, but the open-source pool paid all but a cent: a cent back.
972 assert_eq!(correction(120_000, 100_000, 10_000), -10_000);
973 // Paid entirely by a credit or pool: nothing back.
974 assert_eq!(correction(120_000, 100_000, 0), 0);
Prices keep themselves current with what g1t pays975 }
976
Merge branch 'worktree-agent-a633ac0f7f66d419d'977 fn logs(entries: &[Value]) -> SessionCost {
978 let mut total = SessionCost { complete: true, ..SessionCost::default() };
979 for log in entries {
980 total.add(log);
981 }
982 total
983 }
984
985 #[test]
986 fn a_run_is_settled_at_the_gateways_figure_when_it_priced_every_request() {
987 let gateway = logs(&[
988 json!({ "cost": 0.012, "tokens_in": 4000, "tokens_out": 300, "model": "claude-sonnet-5-5" }),
989 json!({ "cost": "0.003", "tokens_in": 900, "tokens_out": 40, "model": "claude-haiku-4-5" }),
990 // Served from the gateway's own cache: no cost, and none owed.
991 json!({ "cost": 0, "tokens_in": 900, "tokens_out": 40, "cached": true }),
992 // An error with no tokens costs nothing either.
993 json!({ "cost": null, "tokens_in": 0, "tokens_out": 0 }),
994 ]);
995 assert!(gateway.whole());
996 assert_eq!(gateway.requests, 4);
997 // Down from what the sandbox said, or up: the gateway's figure.
998 assert_eq!(settled_cost(20_000, &gateway), (15_000, None));
999 assert_eq!(settled_cost(9_000, &gateway), (15_000, None));
1000 }
1001
1002 #[test]
1003 fn a_model_the_gateway_cannot_price_is_never_settled_down_to_nothing() {
1004 let gateway = logs(&[
1005 json!({ "cost": 0.002, "tokens_in": 100, "tokens_out": 10, "model": "claude-haiku-4-5" }),
1006 json!({ "cost": 0, "tokens_in": 50_000, "tokens_out": 2_000, "model": "claude-new-1" }),
1007 json!({ "tokens_in": 50_000, "tokens_out": 2_000, "model": "claude-new-1" }),
1008 ]);
1009 assert!(!gateway.whole());
1010 assert_eq!((gateway.unpriced, gateway.unpriced_models.clone()), (2, vec!["claude-new-1".to_owned()]));
1011 // The sandbox said $0.90: kept, not cut to the gateway's $0.002.
1012 let (cost, why) = settled_cost(900_000, &gateway);
1013 assert_eq!(cost, 900_000);
1014 assert!(why.unwrap().contains("no price for 2 of its 3 requests (claude-new-1)"));
1015 // A sandbox that reported less than the gateway priced: the gateway's.
1016 assert_eq!(settled_cost(1_000, &gateway).0, 2_000);
1017 }
1018
1019 #[test]
1020 fn more_logs_than_were_read_never_settle_a_run_down() {
1021 let mut gateway = logs(&[json!({ "cost": 1.0, "tokens_in": 1, "tokens_out": 1 })]);
1022 gateway.complete = false;
1023 let (cost, why) = settled_cost(3_000_000, &gateway);
1024 assert_eq!(cost, 3_000_000);
1025 assert!(why.unwrap().contains("more than 1 of its requests"));
1026 }
1027
1028 #[test]
1029 fn a_gateway_figure_over_the_report_cap_is_charged_in_full() {
1030 // A sandbox's report is believed up to $100; the gateway's is not capped.
1031 let gateway = logs(&[json!({ "cost": 140.0, "tokens_in": 1, "tokens_out": 1 })]);
1032 assert_eq!(settled_cost(100_000_000, &gateway).0, 140_000_000);
1033 assert_eq!(crate::charge_micros(140.0, 20), 120_000_000);
1034 assert_eq!(crate::margin_on(140_000_000, 20), 168_000_000);
1035 // Exactly cost plus the margin, rounded up, in whole micros (dollars
1036 // as floats can come out a micro high), never under it.
1037 for cost in [0_i64, 1, 7, 999, 123_457, 99_999_999] {
1038 let exact = (cost * 120 + 99) / 100;
1039 assert_eq!(crate::margin_on(cost, 20), exact, "{cost}");
1040 assert!(crate::margin_on(cost, 20) * 100 >= cost * 120, "{cost}");
1041 assert!(crate::charge_micros(cost as f64 / 1e6, 20) >= exact, "{cost}");
1042 }
1043 }
1044
Prices keep themselves current with what g1t pays1045 #[test]
The keeper reads Cloudflare as it really answers1046 fn a_sandbox_second_is_its_memory_and_disk_and_the_cpu_it_uses() {
1047 // An hour of sandboxes that kept a fifth of a vCPU busy.
1048 let usage = ContainerUsage { cpu_seconds: 720.0, memory_byte_seconds: 3600.0 * 4.0 * GIB };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1049 let micros =
1050 sandbox_second_micros(usage, LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_VCPU_SECOND, LIST_DO_GB_SECOND).unwrap();
1051 // 4 x 2.5 + 8 x 0.07 + 0.125 x 12.5 + 0.2 x 20 = 16.1225
1052 assert!((micros - 16.1225).abs() < 1e-9, "{micros}");
1053 // The Durable Object adds about 11% to the second it left out.
1054 assert!((sandbox_base_micros(LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_DO_GB_SECOND) - 12.1225).abs() < 1e-9);
The keeper reads Cloudflare as it really answers1055 // Too little use to say anything.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1056 assert!(sandbox_second_micros(ContainerUsage { cpu_seconds: 1.0, memory_byte_seconds: GIB }, 1.0, 1.0, 1.0, 1.0).is_none());
1057 }
1058
1059 #[test]
1060 fn a_run_that_reports_its_cpu_is_priced_on_it() {
1061 let base = sandbox_base_micros(LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_DO_GB_SECOND);
1062 let vcpu = LIST_VCPU_SECOND * MICROS_PER_DOLLAR as f64;
1063 // A 10-minute cargo build that kept its half vCPU busy throughout.
1064 let heavy = run_cost(600, 300.0, base, vcpu);
1065 assert!((heavy - (600.0 * 12.1225 + 300.0 * 20.0)).abs() < 1e-6);
1066 // The same ten minutes, mostly idle, costs less.
1067 let light = run_cost(600, 30.0, base, vcpu);
1068 assert!(light < heavy);
1069 // The average would have under-priced the heavy one.
1070 let average = 600.0 * (base + 0.195 * vcpu);
1071 assert!(average < heavy && average > light);
1072 assert_eq!(run_cost(0, -1.0, base, vcpu), 0.0);
Fast pages, required checks on the branch, self-hosted runners, honest incidents1073 // A larger machine's base: its memory and disk, not its CPU.
1074 assert!((base_scale(SANDBOX_GIB, SANDBOX_DISK_GB) - 1.0).abs() < 1e-12);
1075 assert!((base_scale(12.0, 20.0) - 2.72).abs() < 0.01, "{}", base_scale(12.0, 20.0));
1076 assert!((base_scale(8.0, 16.0) - 1.87).abs() < 0.01, "{}", base_scale(8.0, 16.0));
The keeper reads Cloudflare as it really answers1077 }
1078
1079 #[test]
1080 fn a_billed_rate_is_the_median_of_the_charged_days() {
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too1081 let row = |quantity: f64, list_cost: f64| UsageRow {
The keeper reads Cloudflare as it really answers1082 period_start: String::new(),
1083 period_end: String::new(),
1084 service: "Containers / Container Memory".into(),
1085 unit: "Count".into(),
1086 quantity,
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too1087 cost: list_cost,
1088 list_cost,
The keeper reads Cloudflare as it really answers1089 };
1090 let rows = [row(100.0, 0.0), row(100.0, 0.0002), row(100.0, 0.00025), row(100.0, 0.00025)];
1091 assert_eq!(billed_rate(&rows.iter().collect::<Vec<_>>()), Some(0.000_002_5));
1092 assert_eq!(billed_rate(&[&row(5.0, 0.0)]), None);
1093 }
1094
1095 #[test]
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too1096 fn a_rate_is_the_list_price_not_what_was_billed_past_the_included_amount() {
1097 // 126,870 GiB-seconds, of which the 36,870 past the included 90,000
1098 // were billed: $0.09 billed, $0.32 at list. The rate is the list's.
1099 let row = UsageRow {
1100 period_start: String::new(),
1101 period_end: String::new(),
1102 service: "Containers / Container Memory".into(),
1103 unit: "GiB-seconds".into(),
1104 quantity: 126_870.0,
1105 cost: 0.092_175,
1106 list_cost: 0.317_175,
1107 };
1108 assert!((billed_rate(&[&row]).unwrap() - 0.000_002_5).abs() < 1e-15);
1109 // Billed with no list cost says nothing about the price.
1110 assert_eq!(billed_rate(&[&UsageRow { list_cost: 0.0, ..row }]), None);
1111 }
1112
1113 #[test]
Prices keep themselves current with what g1t pays1114 fn usage_rows_are_read_by_their_focus_names() {
1115 let row = UsageRow::from_value(&json!({
1116 "ServiceFamilyName": "Containers",
1117 "ServiceName": "Memory",
The keeper reads Cloudflare as it really answers1118 "PricingUnit": "GiB-seconds",
Prices keep themselves current with what g1t pays1119 "PricingQuantity": "1200.5",
1120 "ContractedCost": 0.003,
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too1121 "ListCost": 0.0030,
Prices keep themselves current with what g1t pays1122 "ChargePeriodStart": "2026-10-01",
1123 }))
1124 .unwrap();
1125 assert_eq!(row.service, "Containers / Memory");
1126 assert_eq!(row.quantity, 1200.5);
1127 assert_eq!(row.cost, 0.003);
Price drift compares the price book with the usage at Cloudflare's list prices, never with what was billed past the included amounts; unit-cost proposals take list rates too1128 assert_eq!(row.list_cost, 0.003);
Prices keep themselves current with what g1t pays1129 assert!(UsageRow::from_value(&json!({ "nothing": 1 })).is_none());
1130 }
1131}

This file's history is long; its oldest lines are credited to the oldest commit read.