Skip to content
1,143 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
Merge remote-tracking branch 'origin/main' into workspace-chat329/// 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()
Merge remote-tracking branch 'origin/main' into workspace-chat338 .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,
Merge remote-tracking branch 'origin/main' into workspace-chat357 /// 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"])),
Merge remote-tracking branch 'origin/main' into workspace-chat385 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>,
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.426 #[serde(default)]
427 agent: Option<String>,
428 #[serde(default)]
429 asked_by: Option<String>,
Prices keep themselves current with what g1t pays430}
431
432#[derive(Deserialize)]
433struct Charged {
434 cost_micros: Option<i64>,
435 description: String,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put436 amount_micros: i64,
437}
438
439/// What a settled run's correction comes to, from the charges at the
440/// reported and the gateway's cost and what the workspace was charged at
441/// first. A charge up is drawn down like any charge; a charge down is
442/// given back only up to what the workspace paid, since what a credit or
443/// a pool paid was never the workspace's money.
444pub(crate) fn correction(reported_charge: i64, gateway_charge: i64, first_charged: i64) -> i64 {
445 let delta = gateway_charge - reported_charge;
446 if delta >= 0 { delta } else { delta.max(-first_charged.max(0)) }
Prices keep themselves current with what g1t pays447}
448
Merge branch 'worktree-agent-a633ac0f7f66d419d'449/// A session's logs are read 50 at a time, up to this many pages.
450const MAX_LOG_PAGES: u32 = 40;
451
452/// What AI Gateway's logs say a session cost.
453#[derive(Clone, Debug, Default, PartialEq)]
454pub(crate) struct SessionCost {
455 /// What the gateway priced the requests at, in dollars.
456 pub cost_usd: f64,
457 pub requests: u32,
458 /// Requests that used tokens but that the gateway put no price on: a
459 /// model it has no price for. Their cost is not in `cost_usd`.
460 pub unpriced: u32,
461 /// The models of those, for the statement and the drift.
462 pub unpriced_models: Vec<String>,
463 /// False when there were more logs than were read.
464 pub complete: bool,
465}
466
467impl SessionCost {
468 /// Adds one log, read leniently: `cost` in dollars, `tokens_in` and
469 /// `tokens_out`, `cached` for an answer from the gateway's own cache
470 /// (which costs nothing).
471 pub(crate) fn add(&mut self, log: &Value) {
472 self.requests += 1;
473 let number = |key: &str| log[key].as_f64().or_else(|| log[key].as_str().and_then(|s| s.parse().ok()));
474 let cost = number("cost").filter(|c| c.is_finite() && *c > 0.0);
475 let tokens = number("tokens_in").unwrap_or(0.0) + number("tokens_out").unwrap_or(0.0);
476 let cached = log["cached"].as_bool().unwrap_or(false);
477 match cost {
478 Some(cost) => self.cost_usd += cost,
479 None if tokens > 0.0 && !cached => {
480 self.unpriced += 1;
481 let model = log["model"].as_str().unwrap_or("an unnamed model").to_owned();
482 if !self.unpriced_models.contains(&model) {
483 self.unpriced_models.push(model);
484 }
485 }
486 None => {}
487 }
488 }
489
490 /// Whether the gateway's figure is the whole of what the run cost.
491 pub(crate) fn whole(&self) -> bool {
492 self.complete && self.unpriced == 0
493 }
494}
495
496/// The cost a run is settled at, in millionths: the gateway's figure when
497/// it priced every request; otherwise (a model it has no price for, or
498/// more logs than were read) never less than the sandbox reported, since
499/// the gateway's sum is then short of what the provider bills. With a
500/// reason for the statement and the drift when it is not the gateway's
501/// figure alone.
502pub(crate) fn settled_cost(reported_micros: i64, gateway: &SessionCost) -> (i64, Option<String>) {
503 // Not held to MAX_RUN_COST_USD: the gateway's figure is trusted.
504 let priced = if gateway.cost_usd.is_finite() { (gateway.cost_usd.max(0.0) * MICROS_PER_DOLLAR as f64).ceil() as i64 } else { 0 };
505 if gateway.whole() {
506 return (priced, None);
507 }
508 let mut why = Vec::new();
509 if gateway.unpriced > 0 {
510 why.push(format!(
511 "AI Gateway has no price for {} of its {} requests ({})",
512 gateway.unpriced,
513 gateway.requests,
514 gateway.unpriced_models.join(", ")
515 ));
516 }
517 if !gateway.complete {
518 why.push(format!("more than {} of its requests were logged", gateway.requests));
519 }
520 (priced.max(reported_micros.max(0)), Some(why.join("; ")))
521}
522
Prices keep themselves current with what g1t pays523fn ms(timestamp: &str) -> u64 {
524 // RFC 3339 in UTC, as g1t writes them.
525 worker::js_sys::Date::parse(timestamp) as u64
526}
527
528impl Billing {
529 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 index530 // Three reads at once; the plans are priced from the rows already
531 // read, not one query per meter (status.g1t.sh times this call).
532 let (prices, changes, coming) = futures_util::future::join3(
533 async { self.db.prepare("SELECT * FROM prices ORDER BY rowid").all().await?.results::<PriceRow>() },
534 async {
535 self.db
536 .prepare("SELECT * FROM price_changes ORDER BY created_at DESC LIMIT 20")
537 .all()
538 .await?
539 .results::<ChangeRow>()
540 },
541 // Changes still to come first, so a rise is seen before it is charged.
542 self.coming_changes(),
543 )
544 .await;
545 let (prices, changes, coming) = (prices?, changes?, coming?);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit546 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 index547 let book: std::collections::BTreeMap<&str, f64> = prices
548 .iter()
549 .map(|row| (row.meter.as_str(), Price::price_for(row.cost_micros, row.markup_percent)))
550 .collect();
Merge Stripe Tax, the card fee on card payments, and one free workspace per person551 // With the card fee on top of each monthly price, as it is charged.
552 let card_fee = self.card_fee().await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar553 let plans: Vec<_> = g1t_contracts::billing::Feature::ALL
554 .iter()
Merge Stripe Tax, the card fee on card payments, and one free workspace per person555 .map(|feature| {
556 let mut plan = match feature {
557 g1t_contracts::billing::Feature::Security => crate::features::security_plan_at(&book),
558 _ => self.plan_at(&book),
559 };
560 plan.card_fee_cents = crate::tax::fee_for(i64::from(plan.monthly_cents), &card_fee) as u32;
561 plan
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar562 })
563 .collect();
Prices keep themselves current with what g1t pays564 Ok(PriceBook {
565 prices: prices
566 .into_iter()
567 .map(|row| Price {
568 price_micros: Price::price_for(row.cost_micros, row.markup_percent),
569 meter: row.meter,
570 title: row.title,
571 unit: row.unit,
572 cost_micros: row.cost_micros,
573 markup_percent: row.markup_percent,
574 source: row.source,
575 checked_at: row.checked_at,
576 updated_at: row.updated_at,
577 })
578 .collect(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily579 changes: coming
Prices keep themselves current with what g1t pays580 .into_iter()
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily581 .chain(changes.into_iter().map(|row| PriceChange {
Prices keep themselves current with what g1t pays582 meter: row.meter,
583 old_cost_micros: row.old_cost_micros,
584 new_cost_micros: row.new_cost_micros,
585 markup_percent: row.markup_percent,
Prices are what g1t pays plus 20%, from the first second586 old_markup_percent: row.old_markup_percent,
Prices keep themselves current with what g1t pays587 reason: row.reason,
588 created_at: row.created_at,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily589 effective_at: None,
590 }))
Prices keep themselves current with what g1t pays591 .collect(),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit592 // The markup on models' provider price: the price book's
593 // `agent_models` (none from 2026-10-08).
594 model_margin_percent: model_markup,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily595 plans,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put596 free: Some(g1t_contracts::billing::FreeTier {
597 trial_workspace_micros: if self.trials_on { self.plans.trial_workspace_micros } else { 0 },
598 trial_monthly_pool_micros: if self.trials_on { self.plans.trial_monthly_pool_micros } else { 0 },
599 oss_pool_micros: self.plans.oss_pool_micros,
600 oss_repo_micros: self.plans.oss_repo_micros,
601 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 sudo602 audit_retention_days: self.plans.free_audit_days,
603 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 put604 min_charge_micros: self.plans.min_charge_micros,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look605 git_operations_included: self.plans.git_included,
606 paid_start_ceiling_micros: self.plans.paid_start_micros,
607 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 put608 }),
Prices keep themselves current with what g1t pays609 })
610 }
611
The keeper reads Cloudflare as it really answers612 /// Whether the costs have never been checked against Cloudflare's bill.
613 pub(crate) async fn never_checked(&self) -> Result<bool> {
614 Ok(self
615 .db
616 .prepare("SELECT meter FROM prices WHERE checked_at IS NOT NULL LIMIT 1")
617 .first::<Value>(None)
618 .await?
619 .is_none())
620 }
621
Prices keep themselves current with what g1t pays622 /// A meter's cost and price per unit, from the book.
623 pub(crate) async fn price(&self, meter: &str) -> Result<Option<(f64, f64)>> {
624 #[derive(Deserialize)]
625 struct Row {
626 cost_micros: f64,
627 markup_percent: u32,
628 }
629 Ok(self
630 .db
631 .prepare("SELECT cost_micros, markup_percent FROM prices WHERE meter = ?")
632 .bind(&[meter.into()])?
633 .first::<Row>(None)
634 .await?
635 .map(|row| (row.cost_micros, Price::price_for(row.cost_micros, row.markup_percent))))
636 }
637
638 /// Corrects finished runs to what AI Gateway priced them at, and
639 /// charges runs whose sandbox died before reporting.
640 pub(crate) async fn settle_runs(&self, keeper: &Keeper) -> Result<()> {
641 if keeper.token.is_none() || keeper.gateway.is_empty() {
642 return Ok(());
643 }
644 let now = now_ms();
645 let runs = self
646 .db
647 .prepare(
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.648 "SELECT id, workspace, repo, number, task, model, token_hash, billed_to, session_id, created_at, finished_at, agent, asked_by
Prices keep themselves current with what g1t pays649 FROM runs
Merge branch 'model-routing'650 WHERE session_id IS NOT NULL AND settled_at IS NULL AND COALESCE(billed_to, 'g1t') = 'g1t'
Prices keep themselves current with what g1t pays651 AND ((finished_at IS NOT NULL AND finished_at < ?1) OR created_at < ?2)
652 ORDER BY created_at LIMIT 10",
653 )
654 .bind(&[rfc3339(now - SETTLE_AFTER_MS).into(), rfc3339(now - ABANDONED_AFTER_MS).into()])?
655 .all()
656 .await?
657 .results::<Unsettled>()?;
658 for run in runs {
Merge branch 'worktree-agent-a633ac0f7f66d419d'659 let gateway = match keeper.session_cost(&run.session_id).await {
Prices keep themselves current with what g1t pays660 Ok(found) => found,
661 Err(error) => {
662 worker::console_error!("could not read gateway logs for {}: {error}", run.id);
663 continue;
664 }
665 };
666 let since = ms(run.finished_at.as_deref().unwrap_or(&run.created_at));
Merge branch 'worktree-agent-a633ac0f7f66d419d'667 if gateway.requests == 0 && now.saturating_sub(since) < GIVE_UP_AFTER_MS {
Prices keep themselves current with what g1t pays668 continue;
669 }
Merge branch 'worktree-agent-a633ac0f7f66d419d'670 self.settle(&run, &gateway).await?;
Prices keep themselves current with what g1t pays671 }
672 Ok(())
673 }
674
Merge branch 'model-routing'675 /// Closes runs on a workspace's own model provider: none is on g1t's
676 /// gateway, so nothing is corrected, but tokens the proxy counted after
677 /// the run reported, or for a sandbox that died before reporting, are
678 /// charged their agent rate now. Needs no gateway token.
679 pub(crate) async fn settle_own_runs(&self) -> Result<()> {
680 #[derive(Deserialize)]
681 struct Own {
682 id: String,
683 workspace: String,
684 repo: String,
685 number: u32,
686 task: String,
687 model: String,
688 token_hash: String,
689 billed_to: Option<String>,
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.690 #[serde(default)]
691 agent: Option<String>,
692 #[serde(default)]
693 asked_by: Option<String>,
Merge branch 'model-routing'694 }
695 let now = now_ms();
696 let runs = self
697 .db
698 .prepare(
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.699 "SELECT id, workspace, repo, number, task, model, token_hash, billed_to, agent, asked_by
Merge branch 'model-routing'700 FROM runs
701 WHERE billed_to = 'workspace' AND session_id IS NOT NULL AND settled_at IS NULL
702 AND ((finished_at IS NOT NULL AND finished_at < ?1) OR created_at < ?2)
703 ORDER BY created_at LIMIT 25",
704 )
705 .bind(&[rfc3339(now - SETTLE_AFTER_MS).into(), rfc3339(now - ABANDONED_AFTER_MS).into()])?
706 .all()
707 .await?
708 .results::<Own>()?;
709 for run in runs {
710 let settled_at = rfc3339(now_ms());
711 let claimed = self
712 .db
713 .prepare(
714 "UPDATE runs SET settled_at = ?1, finished_at = COALESCE(finished_at, ?1)
715 WHERE id = ?2 AND settled_at IS NULL RETURNING id",
716 )
717 .bind(&[settled_at.as_str().into(), run.id.as_str().into()])?
718 .first::<Value>(None)
719 .await?;
720 if claimed.is_none() {
721 continue;
722 }
723 let row = RunRow {
724 workspace: run.workspace,
725 repo: run.repo,
726 number: run.number,
727 task: run.task,
728 model: run.model,
729 token_hash: run.token_hash,
730 billed_to: run.billed_to,
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.731 agent: run.agent,
732 asked_by: run.asked_by,
Merge branch 'model-routing'733 };
734 self.charge_agent_rate(&run.id, &row, None).await?;
735 }
736 Ok(())
737 }
738
Merge branch 'worktree-agent-a633ac0f7f66d419d'739 async fn settle(&self, run: &Unsettled, gateway: &SessionCost) -> Result<()> {
740 let requests = gateway.requests;
Prices keep themselves current with what g1t pays741 let row = RunRow {
742 workspace: run.workspace.clone(),
743 repo: run.repo.clone(),
744 number: run.number,
745 task: run.task.clone(),
746 model: run.model.clone(),
747 token_hash: run.token_hash.clone(),
748 billed_to: run.billed_to.clone(),
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.749 agent: run.agent.clone(),
750 asked_by: run.asked_by.clone(),
Prices keep themselves current with what g1t pays751 };
752 let charged = self
753 .db
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put754 .prepare("SELECT cost_micros, description, amount_micros FROM ledger WHERE reference = ?")
Prices keep themselves current with what g1t pays755 .bind(&[run.id.as_str().into()])?
756 .first::<Charged>(None)
757 .await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'758 let reported = charged.as_ref().and_then(|c| c.cost_micros).unwrap_or(0);
759 // The gateway's figure; never under what the sandbox reported when
760 // the gateway could not price all of it (see `settled_cost`).
761 let (gateway_micros, short) = settled_cost(reported, gateway);
Billing accounts, terms and enterprises; g1t is no longer free762 let terms = self.terms_of(&run.workspace).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit763 // Models at the price book's markup on the provider's price
764 // (`agent_models`), as `finish_run` charges them.
765 let markup = self.model_markup().await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'766 // 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 audit767 // below cost plus the markup (counted as given, see `charged`).
Prices keep themselves current with what g1t pays768 let charge_for = |micros: i64| {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit769 if self.free { (0, 0) } else { terms.discounted(crate::margin_on(micros, markup)) }
Prices keep themselves current with what g1t pays770 };
771 let settled_at = rfc3339(now_ms());
772 // Claim it, so two crons never settle it twice.
773 let claimed = self
774 .db
Merge branch 'worktree-agent-a633ac0f7f66d419d'775 .prepare(
776 "UPDATE runs SET settled_at = ?, gateway_cost_micros = ?, gateway_note = ?, finished_at = COALESCE(finished_at, ?)
777 WHERE id = ? AND settled_at IS NULL RETURNING id",
778 )
Prices keep themselves current with what g1t pays779 .bind(&[
780 settled_at.as_str().into(),
781 (gateway_micros as f64).into(),
Merge branch 'worktree-agent-a633ac0f7f66d419d'782 short.as_deref().map_or(worker::wasm_bindgen::JsValue::NULL, Into::into),
Prices keep themselves current with what g1t pays783 settled_at.as_str().into(),
784 run.id.as_str().into(),
785 ])?
786 .first::<Value>(None)
787 .await?;
788 if claimed.is_none() || requests == 0 {
789 return Ok(());
790 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit791 // Tokens counted after the run reported are charged their agent
792 // rate now (ai.rs).
Merge branch 'model-routing'793 self.charge_agent_rate(&run.id, &row, None).await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'794 if let Some(why) = &short {
795 worker::console_warn!("run {} settled at no less than reported: {why}", run.id);
796 }
797 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 pays798 let free_note = if self.free { " (free while g1t is being built out)" } else { "" };
799 match charged {
800 // Never reported: charged now, from the gateway's figure.
801 None => {
Merge branch 'worktree-agent-a633ac0f7f66d419d'802 let (charge, discount) = charge_for(gateway_micros);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look803 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 put804 let drawn = self.draw(&run.workspace, charge, &settled_at[..7], &eligible).await?;
Prices keep themselves current with what g1t pays805 let description = format!(
Merge branch 'worktree-agent-a633ac0f7f66d419d'806 "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 put807 run.repo,
808 run.number,
809 drawn.note()
Prices keep themselves current with what g1t pays810 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put811 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 pays812 .await?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put813 self.record_drawn(&run.id, &drawn).await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'814 self.record_discount(&run.id, discount).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays815 self.count_spend(&run.workspace, gateway_micros, charge - drawn.total(), &drawn).await;
Prices keep themselves current with what g1t pays816 }
817 Some(charged) => {
818 let delta = gateway_micros - reported;
819 if delta == 0 {
820 return Ok(());
821 }
Merge branch 'worktree-agent-a633ac0f7f66d419d'822 let ((was, was_given), (now, now_given)) = (charge_for(reported), charge_for(gateway_micros));
823 let change = correction(was, now, -charged.amount_micros);
824 // What the discount gives moves with the charge.
825 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 put826 // A charge up is paid for like any other charge.
827 let drawn = if change > 0 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look828 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 put829 self.draw(&run.workspace, change, &settled_at[..7], &eligible).await?
830 } else {
831 crate::credits::Drawn::default()
832 };
Prices keep themselves current with what g1t pays833 let description = format!(
Merge branch 'worktree-agent-a633ac0f7f66d419d'834 "Correction to “{}”: AI Gateway priced its {requests} model requests at {}, not {}{short_note}{}",
Prices keep themselves current with what g1t pays835 charged.description,
836 crate::features::dollars(gateway_micros),
837 crate::features::dollars(reported),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put838 drawn.note(),
Prices keep themselves current with what g1t pays839 );
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put840 let reference = format!("{}/settled", run.id);
Prices keep themselves current with what g1t pays841 self.enter(
842 &run.workspace,
843 EntryKind::Usage,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put844 -(change - drawn.total()),
Prices keep themselves current with what g1t pays845 &description,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put846 &reference,
Prices keep themselves current with what g1t pays847 Some(&row),
848 Some(delta),
849 None,
850 None,
851 )
852 .await?;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put853 self.record_drawn(&reference, &drawn).await?;
Merge branch 'worktree-agent-a633ac0f7f66d419d'854 self.record_discount(&reference, discount).await?;
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays855 self.count_spend(&run.workspace, delta, change - drawn.total(), &drawn).await;
Prices keep themselves current with what g1t pays856 }
857 }
858 Ok(())
859 }
860
861 /// Checks each cost against what Cloudflare billed this month, and
862 /// moves the ones that changed.
863 pub(crate) async fn reconcile(&self, keeper: &Keeper) -> Result<()> {
864 if keeper.token.is_none() {
865 return Ok(());
866 }
867 let now = rfc3339(now_ms());
868 let today = &now[..10];
The keeper reads Cloudflare as it really answers869 let since = rfc3339(now_ms() - 30 * 24 * 60 * 60 * 1000);
870 let rows = keeper.billable_usage(&since[..10], today).await?;
Prices keep themselves current with what g1t pays871 for row in &rows {
872 self.db
873 .prepare(
874 "INSERT INTO cloudflare_usage (period_start, period_end, service, unit, quantity, cost_usd, fetched_at)
875 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
876 ON CONFLICT (period_start, service, unit) DO UPDATE SET
877 period_end = ?2, quantity = ?5, cost_usd = ?6, fetched_at = ?7",
878 )
879 .bind(&[
880 row.period_start.as_str().into(),
881 row.period_end.as_str().into(),
882 row.service.as_str().into(),
883 row.unit.as_str().into(),
884 row.quantity.into(),
885 row.cost.into(),
886 now.as_str().into(),
887 ])?
888 .run()
889 .await?;
890 }
891
The keeper reads Cloudflare as it really answers892 let named = |words: &[&str]| -> Vec<&UsageRow> {
Prices keep themselves current with what g1t pays893 rows.iter()
The keeper reads Cloudflare as it really answers894 .filter(|r| {
895 let service = r.service.to_lowercase();
896 words.iter().all(|word| service.contains(word))
897 })
898 .collect()
Prices keep themselves current with what g1t pays899 };
900
Merge remote-tracking branch 'origin/main' into workspace-chat901 // Containers: each resource at the list cost the bill shows for it
902 // (before the included amounts), or the published rate without one,
The keeper reads Cloudflare as it really answers903 // over how much CPU g1t's sandboxes really use per second.
904 let memory = billed_rate(&named(&["container memory"]));
905 let disk = billed_rate(&named(&["container disk"]));
906 let vcpu = billed_rate(&named(&["container vcpu"]));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look907 let durable_object = billed_rate(&named(&["durable objects", "duration"]));
The keeper reads Cloudflare as it really answers908 let usage = keeper.container_usage(&since[..10], today).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look909 let rates = (
The keeper reads Cloudflare as it really answers910 memory.unwrap_or(LIST_MEMORY_GIB_SECOND),
911 disk.unwrap_or(LIST_DISK_GB_SECOND),
912 vcpu.unwrap_or(LIST_VCPU_SECOND),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look913 durable_object.unwrap_or(LIST_DO_GB_SECOND),
914 );
915 // The parts, for runs that report their own CPU.
Merge remote-tracking branch 'origin/main' into workspace-chat916 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 look917 self.measure("sandbox_base_second", sandbox_base_micros(rates.0, rates.1, rates.3), parts_reason).await?;
918 self.measure("sandbox_cpu_second", rates.2 * MICROS_PER_DOLLAR as f64, parts_reason).await?;
919 if let Some(per_second) = sandbox_second_micros(usage, rates.0, rates.1, rates.2, rates.3) {
The keeper reads Cloudflare as it really answers920 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 look921 let billed = [("memory", memory), ("disk", disk), ("vCPU", vcpu), ("Durable Object duration", durable_object)]
The keeper reads Cloudflare as it really answers922 .iter()
923 .filter(|(_, rate)| rate.is_some())
924 .map(|(name, _)| *name)
925 .collect::<Vec<_>>();
926 let reason = format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look927 "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 answers928 usage.cpu_seconds / instance_seconds,
929 instance_seconds / 3600.0,
930 if billed.is_empty() {
931 "rates are Cloudflare's published ones".to_owned()
932 } else {
Merge remote-tracking branch 'origin/main' into workspace-chat933 format!("{} at the list cost on Cloudflare's bill", billed.join(", "))
The keeper reads Cloudflare as it really answers934 },
935 );
936 for meter in ["sandbox_second", "build_second"] {
937 self.measure(meter, per_second, &reason).await?;
Prices keep themselves current with what g1t pays938 }
939 }
The keeper reads Cloudflare as it really answers940 // Apps run as Workers: per million requests and CPU milliseconds,
941 // 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 put942 // Security scans' CPU follows the same Workers CPU rate.
943 let app_meters: [(&str, &[&str], &str); 3] = [
The keeper reads Cloudflare as it really answers944 ("app_requests", &["workers", "requests"], "requests"),
945 ("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 put946 ("scan_cpu", &["workers cpu"], "CPU ms"),
The keeper reads Cloudflare as it really answers947 ];
948 for (meter, words, unit) in app_meters {
949 if let Some(rate) = billed_rate(&named(words)) {
Merge remote-tracking branch 'origin/main' into workspace-chat950 let reason = format!("Cloudflare's bill lists Workers {unit} at ${:.2} per million", rate * 1e6);
The keeper reads Cloudflare as it really answers951 self.measure(meter, rate * 1e6 * MICROS_PER_DOLLAR as f64, &reason).await?;
Prices keep themselves current with what g1t pays952 }
953 }
954 self.db
955 .prepare("UPDATE prices SET checked_at = ?")
956 .bind(&[now.as_str().into()])?
957 .run()
958 .await?;
959 Ok(())
960 }
961
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily962 /// Proposes moving a meter's cost to a measurement (see `pricing`):
963 /// applied on its own when small, after notice when a rise; left for
964 /// staff when large or suspect.
Prices keep themselves current with what g1t pays965 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 daily966 if let Some(outcome) = self.propose(meter, measured, reason, "keeper").await? {
967 worker::console_log!("{meter}: {outcome}");
Prices keep themselves current with what g1t pays968 }
969 Ok(())
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976
977 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put978 fn a_correction_never_gives_back_what_the_workspace_did_not_pay() {
979 // Up by 2 cents: charged in full (then drawn down like any charge).
980 assert_eq!(correction(100_000, 120_000, 100_000), 20_000);
981 // Down by 2 cents, all of it paid by the workspace: given back.
982 assert_eq!(correction(120_000, 100_000, 120_000), -20_000);
983 // Down, but the open-source pool paid all but a cent: a cent back.
984 assert_eq!(correction(120_000, 100_000, 10_000), -10_000);
985 // Paid entirely by a credit or pool: nothing back.
986 assert_eq!(correction(120_000, 100_000, 0), 0);
Prices keep themselves current with what g1t pays987 }
988
Merge branch 'worktree-agent-a633ac0f7f66d419d'989 fn logs(entries: &[Value]) -> SessionCost {
990 let mut total = SessionCost { complete: true, ..SessionCost::default() };
991 for log in entries {
992 total.add(log);
993 }
994 total
995 }
996
997 #[test]
998 fn a_run_is_settled_at_the_gateways_figure_when_it_priced_every_request() {
999 let gateway = logs(&[
1000 json!({ "cost": 0.012, "tokens_in": 4000, "tokens_out": 300, "model": "claude-sonnet-5-5" }),
1001 json!({ "cost": "0.003", "tokens_in": 900, "tokens_out": 40, "model": "claude-haiku-4-5" }),
1002 // Served from the gateway's own cache: no cost, and none owed.
1003 json!({ "cost": 0, "tokens_in": 900, "tokens_out": 40, "cached": true }),
1004 // An error with no tokens costs nothing either.
1005 json!({ "cost": null, "tokens_in": 0, "tokens_out": 0 }),
1006 ]);
1007 assert!(gateway.whole());
1008 assert_eq!(gateway.requests, 4);
1009 // Down from what the sandbox said, or up: the gateway's figure.
1010 assert_eq!(settled_cost(20_000, &gateway), (15_000, None));
1011 assert_eq!(settled_cost(9_000, &gateway), (15_000, None));
1012 }
1013
1014 #[test]
1015 fn a_model_the_gateway_cannot_price_is_never_settled_down_to_nothing() {
1016 let gateway = logs(&[
1017 json!({ "cost": 0.002, "tokens_in": 100, "tokens_out": 10, "model": "claude-haiku-4-5" }),
1018 json!({ "cost": 0, "tokens_in": 50_000, "tokens_out": 2_000, "model": "claude-new-1" }),
1019 json!({ "tokens_in": 50_000, "tokens_out": 2_000, "model": "claude-new-1" }),
1020 ]);
1021 assert!(!gateway.whole());
1022 assert_eq!((gateway.unpriced, gateway.unpriced_models.clone()), (2, vec!["claude-new-1".to_owned()]));
1023 // The sandbox said $0.90: kept, not cut to the gateway's $0.002.
1024 let (cost, why) = settled_cost(900_000, &gateway);
1025 assert_eq!(cost, 900_000);
1026 assert!(why.unwrap().contains("no price for 2 of its 3 requests (claude-new-1)"));
1027 // A sandbox that reported less than the gateway priced: the gateway's.
1028 assert_eq!(settled_cost(1_000, &gateway).0, 2_000);
1029 }
1030
1031 #[test]
1032 fn more_logs_than_were_read_never_settle_a_run_down() {
1033 let mut gateway = logs(&[json!({ "cost": 1.0, "tokens_in": 1, "tokens_out": 1 })]);
1034 gateway.complete = false;
1035 let (cost, why) = settled_cost(3_000_000, &gateway);
1036 assert_eq!(cost, 3_000_000);
1037 assert!(why.unwrap().contains("more than 1 of its requests"));
1038 }
1039
1040 #[test]
1041 fn a_gateway_figure_over_the_report_cap_is_charged_in_full() {
1042 // A sandbox's report is believed up to $100; the gateway's is not capped.
1043 let gateway = logs(&[json!({ "cost": 140.0, "tokens_in": 1, "tokens_out": 1 })]);
1044 assert_eq!(settled_cost(100_000_000, &gateway).0, 140_000_000);
1045 assert_eq!(crate::charge_micros(140.0, 20), 120_000_000);
1046 assert_eq!(crate::margin_on(140_000_000, 20), 168_000_000);
1047 // Exactly cost plus the margin, rounded up, in whole micros (dollars
1048 // as floats can come out a micro high), never under it.
1049 for cost in [0_i64, 1, 7, 999, 123_457, 99_999_999] {
1050 let exact = (cost * 120 + 99) / 100;
1051 assert_eq!(crate::margin_on(cost, 20), exact, "{cost}");
1052 assert!(crate::margin_on(cost, 20) * 100 >= cost * 120, "{cost}");
1053 assert!(crate::charge_micros(cost as f64 / 1e6, 20) >= exact, "{cost}");
1054 }
1055 }
1056
Prices keep themselves current with what g1t pays1057 #[test]
The keeper reads Cloudflare as it really answers1058 fn a_sandbox_second_is_its_memory_and_disk_and_the_cpu_it_uses() {
1059 // An hour of sandboxes that kept a fifth of a vCPU busy.
1060 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 look1061 let micros =
1062 sandbox_second_micros(usage, LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_VCPU_SECOND, LIST_DO_GB_SECOND).unwrap();
1063 // 4 x 2.5 + 8 x 0.07 + 0.125 x 12.5 + 0.2 x 20 = 16.1225
1064 assert!((micros - 16.1225).abs() < 1e-9, "{micros}");
1065 // The Durable Object adds about 11% to the second it left out.
1066 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 answers1067 // Too little use to say anything.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1068 assert!(sandbox_second_micros(ContainerUsage { cpu_seconds: 1.0, memory_byte_seconds: GIB }, 1.0, 1.0, 1.0, 1.0).is_none());
1069 }
1070
1071 #[test]
1072 fn a_run_that_reports_its_cpu_is_priced_on_it() {
1073 let base = sandbox_base_micros(LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_DO_GB_SECOND);
1074 let vcpu = LIST_VCPU_SECOND * MICROS_PER_DOLLAR as f64;
1075 // A 10-minute cargo build that kept its half vCPU busy throughout.
1076 let heavy = run_cost(600, 300.0, base, vcpu);
1077 assert!((heavy - (600.0 * 12.1225 + 300.0 * 20.0)).abs() < 1e-6);
1078 // The same ten minutes, mostly idle, costs less.
1079 let light = run_cost(600, 30.0, base, vcpu);
1080 assert!(light < heavy);
1081 // The average would have under-priced the heavy one.
1082 let average = 600.0 * (base + 0.195 * vcpu);
1083 assert!(average < heavy && average > light);
1084 assert_eq!(run_cost(0, -1.0, base, vcpu), 0.0);
Fast pages, required checks on the branch, self-hosted runners, honest incidents1085 // A larger machine's base: its memory and disk, not its CPU.
1086 assert!((base_scale(SANDBOX_GIB, SANDBOX_DISK_GB) - 1.0).abs() < 1e-12);
1087 assert!((base_scale(12.0, 20.0) - 2.72).abs() < 0.01, "{}", base_scale(12.0, 20.0));
1088 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 answers1089 }
1090
1091 #[test]
1092 fn a_billed_rate_is_the_median_of_the_charged_days() {
Merge remote-tracking branch 'origin/main' into workspace-chat1093 let row = |quantity: f64, list_cost: f64| UsageRow {
The keeper reads Cloudflare as it really answers1094 period_start: String::new(),
1095 period_end: String::new(),
1096 service: "Containers / Container Memory".into(),
1097 unit: "Count".into(),
1098 quantity,
Merge remote-tracking branch 'origin/main' into workspace-chat1099 cost: list_cost,
1100 list_cost,
The keeper reads Cloudflare as it really answers1101 };
1102 let rows = [row(100.0, 0.0), row(100.0, 0.0002), row(100.0, 0.00025), row(100.0, 0.00025)];
1103 assert_eq!(billed_rate(&rows.iter().collect::<Vec<_>>()), Some(0.000_002_5));
1104 assert_eq!(billed_rate(&[&row(5.0, 0.0)]), None);
1105 }
1106
1107 #[test]
Merge remote-tracking branch 'origin/main' into workspace-chat1108 fn a_rate_is_the_list_price_not_what_was_billed_past_the_included_amount() {
1109 // 126,870 GiB-seconds, of which the 36,870 past the included 90,000
1110 // were billed: $0.09 billed, $0.32 at list. The rate is the list's.
1111 let row = UsageRow {
1112 period_start: String::new(),
1113 period_end: String::new(),
1114 service: "Containers / Container Memory".into(),
1115 unit: "GiB-seconds".into(),
1116 quantity: 126_870.0,
1117 cost: 0.092_175,
1118 list_cost: 0.317_175,
1119 };
1120 assert!((billed_rate(&[&row]).unwrap() - 0.000_002_5).abs() < 1e-15);
1121 // Billed with no list cost says nothing about the price.
1122 assert_eq!(billed_rate(&[&UsageRow { list_cost: 0.0, ..row }]), None);
1123 }
1124
1125 #[test]
Prices keep themselves current with what g1t pays1126 fn usage_rows_are_read_by_their_focus_names() {
1127 let row = UsageRow::from_value(&json!({
1128 "ServiceFamilyName": "Containers",
1129 "ServiceName": "Memory",
The keeper reads Cloudflare as it really answers1130 "PricingUnit": "GiB-seconds",
Prices keep themselves current with what g1t pays1131 "PricingQuantity": "1200.5",
1132 "ContractedCost": 0.003,
Merge remote-tracking branch 'origin/main' into workspace-chat1133 "ListCost": 0.0030,
Prices keep themselves current with what g1t pays1134 "ChargePeriodStart": "2026-10-01",
1135 }))
1136 .unwrap();
1137 assert_eq!(row.service, "Containers / Memory");
1138 assert_eq!(row.quantity, 1200.5);
1139 assert_eq!(row.cost, 0.003);
Merge remote-tracking branch 'origin/main' into workspace-chat1140 assert_eq!(row.list_cost, 0.003);
Prices keep themselves current with what g1t pays1141 assert!(UsageRow::from_value(&json!({ "nothing": 1 })).is_none());
1142 }
1143}

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