Skip to content
654 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.

The Packages page names every registry it has1//! 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
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.4//! cost under it is. One job keeps them right, on the billing service's
5//! daily cron:
The Packages page names every registry it has6//!
7//! - **Checking costs** (daily). What Cloudflare billed g1t's account, from
8//! its usage API, is measured against how much was used: Containers
9//! against the seconds containers ran, Workers for Platforms per request
10//! and per CPU millisecond. When a measured cost moves, the price book
11//! moves with it, since each price is its cost plus a set markup, and
12//! the change is recorded where anyone can see it. A measurement goes
13//! through `pricing` as a proposal: a small move is applied on its own (a
14//! rise only after customers have had notice), a large or suspect one
15//! waits for staff in sudo, so one odd day of data cannot reprice
16//! everything.
17
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.18use g1t_contracts::billing::{MICROS_PER_DOLLAR, Price, PriceBook, PriceChange};
The Packages page names every registry it has19
20use g1t_contracts::time::rfc3339;
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use serde_json::{Value, json};
24use worker::{Env, Fetch, Headers, Method, Request, RequestInit, Result};
25
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.26use crate::Billing;
The Packages page names every registry it has27
28/// The cron that also checks costs against Cloudflare's bill.
29pub(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)30/// The quarter-hourly tick: settling, and once an hour the platform watch.
31pub(crate) const QUARTER_HOURLY: &str = "*/15 * * * *";
The Packages page names every registry it has32
33/// Where the keeper reads what g1t pays.
34pub(crate) struct Keeper {
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.35 /// `CLOUDFLARE_USAGE_TOKEN`: Billing and Account Analytics, read only.
The Packages page names every registry it has36 token: Option<String>,
37 /// What reads the bill for `costs`: `CLOUDFLARE_BILLING_TOKEN`
38 /// (Account: Billing Read and Account Analytics Read), or the usage
39 /// token, which has both.
40 billing_token: Option<String>,
41 account: String,
42}
43
44impl Keeper {
45 pub(crate) fn from_env(env: &Env) -> Self {
46 let var = |name: &str| env.var(name).map(|v| v.to_string()).unwrap_or_default();
47 let secret = |name: &str| env.secret(name).ok().map(|v| v.to_string()).filter(|v| !v.is_empty());
48 let token = secret("CLOUDFLARE_USAGE_TOKEN");
49 Keeper {
50 billing_token: secret("CLOUDFLARE_BILLING_TOKEN").or_else(|| token.clone()),
51 token,
52 account: var("CLOUDFLARE_ACCOUNT_ID"),
53 }
54 }
55
56 async fn send(&self, method: Method, url: &str, body: Option<Value>) -> Result<Value> {
57 let Some(token) = &self.token else {
58 return Err(worker::Error::RustError("no CLOUDFLARE_USAGE_TOKEN".into()));
59 };
60 send_with(token, method, url, body).await
61 }
62
63 /// Whether Cloudflare's bill can be read.
64 pub(crate) fn can_read_bill(&self) -> bool {
65 self.billing_token.is_some() && !self.account.is_empty()
66 }
67
68 fn billing_token(&self) -> Result<&str> {
69 self.billing_token
70 .as_deref()
71 .ok_or_else(|| worker::Error::RustError("no CLOUDFLARE_BILLING_TOKEN or CLOUDFLARE_USAGE_TOKEN".into()))
72 }
73
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)74 /// Billable usage from `from` to `to` (dates), every page of it, as one
75 /// answer (`result` holds all the rows), and how many pages it took.
76 /// An answer that says it failed is returned as it is.
77 pub(crate) async fn billable_usage_pages(&self, from: &str, to: &str) -> Result<(Value, u32)> {
78 usage_pages(self.billing_token()?, &self.api(&format!("/billable-usage?from={from}&to={to}"))).await
The Packages page names every registry it has79 }
80
Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing81 /// The account's subscriptions (Workers Paid, add-ons), as Cloudflare
82 /// answers them. Needs Account: Billing Read.
83 pub(crate) async fn subscriptions_body(&self) -> Result<Value> {
84 send_with(self.billing_token()?, Method::Get, &self.api("/subscriptions"), None).await
85 }
86
The Packages page names every registry it has87 /// A GraphQL Analytics query, as Cloudflare answers it, errors and all.
88 pub(crate) async fn graphql(&self, body: Value) -> Result<Value> {
89 send_with(self.billing_token()?, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body)).await
90 }
91
92 pub(crate) fn account(&self) -> &str {
93 &self.account
94 }
95
96 fn api(&self, path: &str) -> String {
97 format!("https://api.cloudflare.com/client/v4/accounts/{}{path}", self.account)
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises98 }
99
The Packages page names every registry it has100 /// The account's billable usage, one row per service per day, as
101 /// Cloudflare reports it.
102 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)103 let Some(token) = &self.token else {
104 return Err(worker::Error::RustError("no CLOUDFLARE_USAGE_TOKEN".into()));
105 };
106 let (body, _) = usage_pages(token, &self.api(&format!("/billable-usage?from={from}&to={to}"))).await?;
The Packages page names every registry it has107 let rows = body["result"].as_array().cloned().unwrap_or_default();
108 Ok(rows.iter().filter_map(UsageRow::from_value).collect())
109 }
110
111 /// What g1t's containers used from `since` to `until` (dates), as
112 /// Cloudflare bills it: memory in byte-seconds, and CPU seconds.
113 async fn container_usage(&self, since: &str, until: &str) -> Result<ContainerUsage> {
114 let query = "query ($account: String!, $since: Date!, $until: Date!) {
115 viewer { accounts(filter: { accountTag: $account }) {
116 containersUsageAdaptiveGroups(limit: 1000, filter: { date_geq: $since, date_leq: $until }) {
117 sum { cpuTimeSec allocatedMemory }
118 }
119 } }
120 }";
121 let body = self
122 .send(
123 Method::Post,
124 "https://api.cloudflare.com/client/v4/graphql",
125 Some(json!({ "query": query, "variables": { "account": self.account, "since": since, "until": until } })),
126 )
127 .await?;
128 let groups = body["data"]["viewer"]["accounts"][0]["containersUsageAdaptiveGroups"]
129 .as_array()
130 .cloned()
131 .unwrap_or_default();
132 Ok(groups.iter().fold(ContainerUsage::default(), |total, g| ContainerUsage {
133 cpu_seconds: total.cpu_seconds + g["sum"]["cpuTimeSec"].as_f64().unwrap_or(0.0),
134 memory_byte_seconds: total.memory_byte_seconds + g["sum"]["allocatedMemory"].as_f64().unwrap_or(0.0),
135 }))
136 }
Merge costs and margin review: gateway query, own spend, discount meters, superseded rises137}
138
The Packages page names every registry it has139/// A request to Cloudflare's API with a bearer token; anything but 200 is
140/// 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)141/// The most pages of billable usage read in one go: far more than a few
142/// months of g1t's meters.
143const MAX_USAGE_PAGES: u32 = 50;
144
145/// Every page of a billable-usage answer, its rows together. Before
146/// 2026-10-09 only the first page was read.
147async fn usage_pages(token: &str, url: &str) -> Result<(Value, u32)> {
148 let mut rows: Vec<Value> = Vec::new();
149 let mut pages = 0;
150 let mut next = url.to_owned();
151 loop {
152 let body = send_with(token, Method::Get, &next, None).await?;
153 if body["success"] == Value::Bool(false) {
154 return Ok((body, pages + 1));
155 }
156 pages += 1;
157 rows.extend(body["result"].as_array().cloned().unwrap_or_default());
158 match crate::costs::next_page(&body, pages).filter(|_| pages < MAX_USAGE_PAGES) {
159 Some(query) => next = format!("{url}&{query}"),
160 None => break,
161 }
162 }
163 Ok((json!({ "success": true, "result": rows }), pages))
164}
165
The Packages page names every registry it has166async fn send_with(token: &str, method: Method, url: &str, body: Option<Value>) -> Result<Value> {
167 let headers = Headers::new();
168 headers.set("authorization", &format!("Bearer {token}"))?;
169 headers.set("content-type", "application/json")?;
170 let mut init = RequestInit::new();
171 init.with_method(method).with_headers(headers);
172 if let Some(body) = body {
173 init.with_body(Some(body.to_string().into()));
174 }
175 let mut response = Fetch::Request(Request::new_with_init(url, &init)?).send().await?;
176 let status = response.status_code();
177 let value: Value = response.json().await.unwrap_or(Value::Null);
178 if status != 200 {
179 return Err(worker::Error::RustError(format!("Cloudflare answered {status}: {value}")));
180 }
181 Ok(value)
182}
183
184#[derive(Debug, Default, Clone, Copy)]
185pub(crate) struct ContainerUsage {
186 cpu_seconds: f64,
187 memory_byte_seconds: f64,
188}
189
Every sandbox on g1t's runners has 20 GB of disk, the most Cloudflare Containers give one, as much or more than a GitHub-hosted ubuntu-latest's 14 GB: the standard machine (ubuntu-latest and every other hosted label, and all other work) becomes a custom instance type of 1 vCPU, 4 GiB and 20 GB, where standard-1 had half a vCPU and 8 GB, and g1t-2core a custom 2 vCPU, 8 GiB and 20 GB, where standard-3 had 16 GB; g1t-4core stays standard-4, which already had 20 GB. 1 vCPU is the smallest a custom type may have, and Cloudflare bills CPU only as it is used, so the doubled vCPU costs only what a job uses of it. The contracts' instance table, its TypeScript mirror and the billing keeper's standard sandbox say the same sizes; the price scales, worked out as before from Cloudflare's list prices with every vCPU busy and the Durable Object behind each, are now 1.9 for g1t-2core and 3.4 for g1t-4core, against the larger standard machine, and the fallbacks for a sandbox second (33 millionths of a dollar with its vCPU busy) and a build second (16) follow it. A test holds wrangler.jsonc's sizes to the contracts', and another the scales to the list prices. The docs' machine tables, the larger machines' prices and the Cloudflare deploy guide say so.190/// g1t's standard sandbox (`STANDARD_INSTANCE` in the contracts): a custom
191/// Containers instance type of 1 vCPU, 4 GiB and 20 GB of disk.
The Packages page names every registry it has192const SANDBOX_GIB: f64 = 4.0;
Every sandbox on g1t's runners has 20 GB of disk, the most Cloudflare Containers give one, as much or more than a GitHub-hosted ubuntu-latest's 14 GB: the standard machine (ubuntu-latest and every other hosted label, and all other work) becomes a custom instance type of 1 vCPU, 4 GiB and 20 GB, where standard-1 had half a vCPU and 8 GB, and g1t-2core a custom 2 vCPU, 8 GiB and 20 GB, where standard-3 had 16 GB; g1t-4core stays standard-4, which already had 20 GB. 1 vCPU is the smallest a custom type may have, and Cloudflare bills CPU only as it is used, so the doubled vCPU costs only what a job uses of it. The contracts' instance table, its TypeScript mirror and the billing keeper's standard sandbox say the same sizes; the price scales, worked out as before from Cloudflare's list prices with every vCPU busy and the Durable Object behind each, are now 1.9 for g1t-2core and 3.4 for g1t-4core, against the larger standard machine, and the fallbacks for a sandbox second (33 millionths of a dollar with its vCPU busy) and a build second (16) follow it. A test holds wrangler.jsonc's sizes to the contracts', and another the scales to the list prices. The docs' machine tables, the larger machines' prices and the Cloudflare deploy guide say so.193const SANDBOX_DISK_GB: f64 = 20.0;
The Packages page names every registry it has194const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
195/// The Durable Object behind each container is billed for as long as the
196/// container runs, at 128 MB.
197const SANDBOX_DO_GB: f64 = 0.125;
198
199/// Cloudflare's published Containers rates, in dollars, used for any rate
200/// the bill does not show yet (while usage is inside the included amount).
201const LIST_MEMORY_GIB_SECOND: f64 = 0.000_002_5;
202const LIST_DISK_GB_SECOND: f64 = 0.000_000_07;
203const LIST_VCPU_SECOND: f64 = 0.000_02;
204/// Durable Objects duration: $12.50 per million GB-seconds.
205const LIST_DO_GB_SECOND: f64 = 0.000_012_5;
206
207/// What one second of a sandbox costs whatever it does, in millionths of a
208/// dollar: its memory and disk, and the Durable Object behind it, for the
209/// whole second. CPU is billed only while busy, on top.
210pub(crate) fn sandbox_base_micros(memory: f64, disk: f64, durable_object: f64) -> f64 {
211 (SANDBOX_GIB * memory + SANDBOX_DISK_GB * disk + SANDBOX_DO_GB * durable_object) * MICROS_PER_DOLLAR as f64
212}
213
214/// What one second of a sandbox costs on average, in millionths of a
215/// dollar: its base, and the CPU sandboxes actually use per second of
216/// running. Runs that report their own CPU are priced on it instead (see
217/// `run_cost`).
218pub(crate) fn sandbox_second_micros(usage: ContainerUsage, memory: f64, disk: f64, vcpu: f64, durable_object: f64) -> Option<f64> {
219 let instance_seconds = usage.memory_byte_seconds / (SANDBOX_GIB * GIB);
220 if instance_seconds < 3600.0 {
221 return None;
222 }
223 let cpu_share = usage.cpu_seconds / instance_seconds;
224 Some(sandbox_base_micros(memory, disk, durable_object) + cpu_share * vcpu * MICROS_PER_DOLLAR as f64)
225}
226
227/// How much more a second of a larger machine's memory and disk (and the
228/// Durable Object behind it) costs than the standard sandbox's, at
229/// Cloudflare's list rates: 1 for the standard machine.
230pub(crate) fn base_scale(memory_gib: f64, disk_gb: f64) -> f64 {
231 let base = |memory: f64, disk: f64| memory * LIST_MEMORY_GIB_SECOND + disk * LIST_DISK_GB_SECOND + SANDBOX_DO_GB * LIST_DO_GB_SECOND;
232 base(memory_gib, disk_gb) / base(SANDBOX_GIB, SANDBOX_DISK_GB)
233}
234
235/// What a run that reported its own CPU cost g1t: its base for every
236/// second, and its vCPU-seconds at the vCPU rate.
237pub(crate) fn run_cost(seconds: i64, cpu_seconds: f64, base_per_second: f64, per_vcpu_second: f64) -> f64 {
238 seconds.max(0) as f64 * base_per_second + cpu_seconds.max(0.0) * per_vcpu_second
239}
240
Merge remote-tracking branch 'origin/main' into workspace-chat241/// A unit's rate from the bill: the median, over the days with a list
242/// cost, of list cost over quantity. Never what was billed: that is net of
243/// the included amounts (nothing while a cycle is inside them, part of a
244/// day's usage on the day it passes one), so over all of the quantity it
245/// reads as a lower price when nothing changed. None without a list cost;
246/// the published rates stand in.
The Packages page names every registry it has247pub(crate) fn billed_rate(rows: &[&UsageRow]) -> Option<f64> {
248 let mut rates: Vec<f64> = rows
249 .iter()
Merge remote-tracking branch 'origin/main' into workspace-chat250 .filter(|r| r.list_cost > 0.0 && r.quantity > 0.0)
251 .map(|r| r.list_cost / r.quantity)
The Packages page names every registry it has252 .collect();
253 if rates.is_empty() {
254 return None;
255 }
256 rates.sort_by(f64::total_cmp);
257 Some(rates[rates.len() / 2])
258}
259
260/// One line of Cloudflare's billable usage.
261#[derive(Debug, Clone)]
262pub(crate) struct UsageRow {
263 period_start: String,
264 period_end: String,
265 service: String,
266 unit: String,
267 quantity: f64,
268 cost: f64,
Merge remote-tracking branch 'origin/main' into workspace-chat269 /// The quantity at list price, before the included amounts.
270 list_cost: f64,
The Packages page names every registry it has271}
272
273impl UsageRow {
274 /// Read leniently: the API is new, and its field names are FOCUS's.
275 fn from_value(row: &Value) -> Option<Self> {
276 let text = |keys: &[&str]| keys.iter().find_map(|k| row[*k].as_str()).unwrap_or_default().to_owned();
277 let number = |keys: &[&str]| {
278 keys.iter()
279 .find_map(|k| row[*k].as_f64().or_else(|| row[*k].as_str().and_then(|s| s.parse().ok())))
280 .unwrap_or(0.0)
281 };
282 let service = text(&["ServiceName", "service_name", "service"]);
283 if service.is_empty() {
284 return None;
285 }
286 let family = text(&["ServiceFamilyName", "service_family_name"]);
287 Some(UsageRow {
288 period_start: text(&["ChargePeriodStart", "charge_period_start"]),
289 period_end: text(&["ChargePeriodEnd", "charge_period_end"]),
290 service: if family.is_empty() { service } else { format!("{family} / {service}") },
291 unit: text(&["PricingUnit", "ConsumedUnit", "consumed_unit"]),
292 quantity: number(&["PricingQuantity", "ConsumedQuantity", "pricing_quantity"]),
293 // What g1t pays; list price if nothing was contracted.
294 cost: Some(number(&["ContractedCost", "BilledCost", "contracted_cost"]))
295 .filter(|cost| *cost > 0.0)
296 .unwrap_or_else(|| number(&["ListCost", "list_cost"])),
Merge remote-tracking branch 'origin/main' into workspace-chat297 list_cost: number(&["ListCost", "list_cost"]),
The Packages page names every registry it has298 })
299 }
300}
301
302#[derive(Deserialize)]
303struct PriceRow {
304 meter: String,
305 title: String,
306 unit: String,
307 cost_micros: f64,
308 markup_percent: u32,
309 source: String,
310 checked_at: Option<String>,
311 updated_at: String,
312}
313
314#[derive(Deserialize)]
315struct ChangeRow {
316 meter: String,
317 old_cost_micros: f64,
318 new_cost_micros: f64,
319 markup_percent: u32,
320 old_markup_percent: Option<u32>,
321 reason: String,
322 created_at: String,
Merge branch 'worktree-agent-a633ac0f7f66d419d'323}
324
The Packages page names every registry it has325impl Billing {
326 pub(crate) async fn prices(&self) -> Result<PriceBook> {
327 // Three reads at once; the plans are priced from the rows already
328 // read, not one query per meter (status.g1t.sh times this call).
329 let (prices, changes, coming) = futures_util::future::join3(
330 async { self.db.prepare("SELECT * FROM prices ORDER BY rowid").all().await?.results::<PriceRow>() },
331 async {
332 self.db
333 .prepare("SELECT * FROM price_changes ORDER BY created_at DESC LIMIT 20")
334 .all()
335 .await?
336 .results::<ChangeRow>()
337 },
338 // Changes still to come first, so a rise is seen before it is charged.
339 self.coming_changes(),
340 )
341 .await;
342 let (prices, changes, coming) = (prices?, changes?, coming?);
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.343 // Agents' and models' meters, from before g1t stopped running
344 // agents, stay in the table but are no longer prices.
345 let prices: Vec<PriceRow> = prices.into_iter().filter(|row| !crate::pricing::retired(&row.meter)).collect();
346 let changes: Vec<ChangeRow> = changes.into_iter().filter(|row| !crate::pricing::retired(&row.meter)).collect();
347 let coming: Vec<PriceChange> = coming.into_iter().filter(|change| !crate::pricing::retired(&change.meter)).collect();
The Packages page names every registry it has348 let book: std::collections::BTreeMap<&str, f64> = prices
349 .iter()
350 .map(|row| (row.meter.as_str(), Price::price_for(row.cost_micros, row.markup_percent)))
351 .collect();
Merge Stripe Tax, the card fee on card payments, and one free workspace per person352 // With the card fee on top of each monthly price, as it is charged.
353 let card_fee = self.card_fee().await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar354 let plans: Vec<_> = g1t_contracts::billing::Feature::ALL
355 .iter()
Merge Stripe Tax, the card fee on card payments, and one free workspace per person356 .map(|feature| {
357 let mut plan = match feature {
358 g1t_contracts::billing::Feature::Security => crate::features::security_plan_at(&book),
359 _ => self.plan_at(&book),
360 };
361 plan.card_fee_cents = crate::tax::fee_for(i64::from(plan.monthly_cents), &card_fee) as u32;
362 plan
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar363 })
364 .collect();
The Packages page names every registry it has365 Ok(PriceBook {
366 prices: prices
367 .into_iter()
368 .map(|row| Price {
369 price_micros: Price::price_for(row.cost_micros, row.markup_percent),
370 meter: row.meter,
371 title: row.title,
372 unit: row.unit,
373 cost_micros: row.cost_micros,
374 markup_percent: row.markup_percent,
375 source: row.source,
376 checked_at: row.checked_at,
377 updated_at: row.updated_at,
378 })
379 .collect(),
380 changes: coming
381 .into_iter()
382 .chain(changes.into_iter().map(|row| PriceChange {
383 meter: row.meter,
384 old_cost_micros: row.old_cost_micros,
385 new_cost_micros: row.new_cost_micros,
386 markup_percent: row.markup_percent,
387 old_markup_percent: row.old_markup_percent,
388 reason: row.reason,
389 created_at: row.created_at,
390 effective_at: None,
391 }))
392 .collect(),
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.393 // Nothing is charged for models any more; kept for readers.
394 model_margin_percent: self.margin_percent,
The Packages page names every registry it has395 plans,
396 free: Some(g1t_contracts::billing::FreeTier {
397 trial_workspace_micros: if self.trials_on { self.plans.trial_workspace_micros } else { 0 },
398 trial_monthly_pool_micros: if self.trials_on { self.plans.trial_monthly_pool_micros } else { 0 },
399 oss_pool_micros: self.plans.oss_pool_micros,
400 oss_repo_micros: self.plans.oss_repo_micros,
401 free_private_storage_bytes: self.plans.free_storage_bytes,
402 audit_retention_days: self.plans.free_audit_days,
403 plan_audit_retention_days: self.plans.audit_days,
404 min_charge_micros: self.plans.min_charge_micros,
405 git_operations_included: self.plans.git_included,
406 paid_start_ceiling_micros: self.plans.paid_start_micros,
407 overage_forgive_cost_micros: self.plans.forgive_cost_micros,
408 }),
409 })
410 }
411
412 /// Whether the costs have never been checked against Cloudflare's bill.
413 pub(crate) async fn never_checked(&self) -> Result<bool> {
414 Ok(self
415 .db
416 .prepare("SELECT meter FROM prices WHERE checked_at IS NOT NULL LIMIT 1")
417 .first::<Value>(None)
418 .await?
419 .is_none())
420 }
421
422 /// A meter's cost and price per unit, from the book.
423 pub(crate) async fn price(&self, meter: &str) -> Result<Option<(f64, f64)>> {
424 #[derive(Deserialize)]
425 struct Row {
426 cost_micros: f64,
427 markup_percent: u32,
428 }
429 Ok(self
430 .db
431 .prepare("SELECT cost_micros, markup_percent FROM prices WHERE meter = ?")
432 .bind(&[meter.into()])?
433 .first::<Row>(None)
434 .await?
435 .map(|row| (row.cost_micros, Price::price_for(row.cost_micros, row.markup_percent))))
436 }
437
438 pub(crate) async fn reconcile(&self, keeper: &Keeper) -> Result<()> {
439 if keeper.token.is_none() {
440 return Ok(());
441 }
442 let now = rfc3339(now_ms());
443 let today = &now[..10];
444 let since = rfc3339(now_ms() - 30 * 24 * 60 * 60 * 1000);
445 let rows = keeper.billable_usage(&since[..10], today).await?;
446 for row in &rows {
447 self.db
448 .prepare(
449 "INSERT INTO cloudflare_usage (period_start, period_end, service, unit, quantity, cost_usd, fetched_at)
450 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
451 ON CONFLICT (period_start, service, unit) DO UPDATE SET
452 period_end = ?2, quantity = ?5, cost_usd = ?6, fetched_at = ?7",
453 )
454 .bind(&[
455 row.period_start.as_str().into(),
456 row.period_end.as_str().into(),
457 row.service.as_str().into(),
458 row.unit.as_str().into(),
459 row.quantity.into(),
460 row.cost.into(),
461 now.as_str().into(),
462 ])?
463 .run()
464 .await?;
465 }
466
467 let named = |words: &[&str]| -> Vec<&UsageRow> {
468 rows.iter()
469 .filter(|r| {
470 let service = r.service.to_lowercase();
471 words.iter().all(|word| service.contains(word))
472 })
473 .collect()
474 };
475
Merge remote-tracking branch 'origin/main' into workspace-chat476 // Containers: each resource at the list cost the bill shows for it
477 // (before the included amounts), or the published rate without one,
The Packages page names every registry it has478 // over how much CPU g1t's sandboxes really use per second.
479 let memory = billed_rate(&named(&["container memory"]));
480 let disk = billed_rate(&named(&["container disk"]));
481 let vcpu = billed_rate(&named(&["container vcpu"]));
482 let durable_object = billed_rate(&named(&["durable objects", "duration"]));
483 let usage = keeper.container_usage(&since[..10], today).await?;
484 let rates = (
485 memory.unwrap_or(LIST_MEMORY_GIB_SECOND),
486 disk.unwrap_or(LIST_DISK_GB_SECOND),
487 vcpu.unwrap_or(LIST_VCPU_SECOND),
488 durable_object.unwrap_or(LIST_DO_GB_SECOND),
489 );
490 // The parts, for runs that report their own CPU.
Merge remote-tracking branch 'origin/main' into workspace-chat491 let parts_reason = "Cloudflare's Containers and Durable Objects rates, as listed on the bill or published";
The Packages page names every registry it has492 self.measure("sandbox_base_second", sandbox_base_micros(rates.0, rates.1, rates.3), parts_reason).await?;
493 self.measure("sandbox_cpu_second", rates.2 * MICROS_PER_DOLLAR as f64, parts_reason).await?;
494 if let Some(per_second) = sandbox_second_micros(usage, rates.0, rates.1, rates.2, rates.3) {
495 let instance_seconds = usage.memory_byte_seconds / (SANDBOX_GIB * GIB);
496 let billed = [("memory", memory), ("disk", disk), ("vCPU", vcpu), ("Durable Object duration", durable_object)]
497 .iter()
498 .filter(|(_, rate)| rate.is_some())
499 .map(|(name, _)| *name)
500 .collect::<Vec<_>>();
501 let reason = format!(
502 "Sandboxes used {:.2} vCPU per second over {:.0} hours of Cloudflare Containers in the last 30 days, with the Durable Object behind each; {}",
503 usage.cpu_seconds / instance_seconds,
504 instance_seconds / 3600.0,
505 if billed.is_empty() {
506 "rates are Cloudflare's published ones".to_owned()
507 } else {
Merge remote-tracking branch 'origin/main' into workspace-chat508 format!("{} at the list cost on Cloudflare's bill", billed.join(", "))
The Packages page names every registry it has509 },
510 );
511 for meter in ["sandbox_second", "build_second"] {
512 self.measure(meter, per_second, &reason).await?;
513 }
514 }
515 // Apps run as Workers: per million requests and CPU milliseconds,
516 // once the bill shows them charged.
517 // Security scans' CPU follows the same Workers CPU rate.
518 let app_meters: [(&str, &[&str], &str); 3] = [
519 ("app_requests", &["workers", "requests"], "requests"),
520 ("app_cpu", &["workers cpu"], "CPU ms"),
521 ("scan_cpu", &["workers cpu"], "CPU ms"),
522 ];
523 for (meter, words, unit) in app_meters {
524 if let Some(rate) = billed_rate(&named(words)) {
Merge remote-tracking branch 'origin/main' into workspace-chat525 let reason = format!("Cloudflare's bill lists Workers {unit} at ${:.2} per million", rate * 1e6);
The Packages page names every registry it has526 self.measure(meter, rate * 1e6 * MICROS_PER_DOLLAR as f64, &reason).await?;
527 }
528 }
529 self.db
530 .prepare("UPDATE prices SET checked_at = ?")
531 .bind(&[now.as_str().into()])?
532 .run()
533 .await?;
534 Ok(())
535 }
536
537 /// Proposes moving a meter's cost to a measurement (see `pricing`):
538 /// applied on its own when small, after notice when a rise; left for
539 /// staff when large or suspect.
540 async fn measure(&self, meter: &str, measured: f64, reason: &str) -> Result<()> {
541 if let Some(outcome) = self.propose(meter, measured, reason, "keeper").await? {
542 worker::console_log!("{meter}: {outcome}");
543 }
544 Ok(())
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.553 fn a_cost_plus_the_margin_rounds_up_to_a_whole_micro() {
Merge branch 'worktree-agent-a633ac0f7f66d419d'554 assert_eq!(crate::margin_on(140_000_000, 20), 168_000_000);
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.555 // Exactly cost plus the margin, rounded up, in whole micros, never under it.
Merge branch 'worktree-agent-a633ac0f7f66d419d'556 for cost in [0_i64, 1, 7, 999, 123_457, 99_999_999] {
557 let exact = (cost * 120 + 99) / 100;
558 assert_eq!(crate::margin_on(cost, 20), exact, "{cost}");
559 assert!(crate::margin_on(cost, 20) * 100 >= cost * 120, "{cost}");
560 }
561 }
562
The Packages page names every registry it has563 #[test]
564 fn a_sandbox_second_is_its_memory_and_disk_and_the_cpu_it_uses() {
565 // An hour of sandboxes that kept a fifth of a vCPU busy.
566 let usage = ContainerUsage { cpu_seconds: 720.0, memory_byte_seconds: 3600.0 * 4.0 * GIB };
567 let micros =
568 sandbox_second_micros(usage, LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_VCPU_SECOND, LIST_DO_GB_SECOND).unwrap();
Every sandbox on g1t's runners has 20 GB of disk, the most Cloudflare Containers give one, as much or more than a GitHub-hosted ubuntu-latest's 14 GB: the standard machine (ubuntu-latest and every other hosted label, and all other work) becomes a custom instance type of 1 vCPU, 4 GiB and 20 GB, where standard-1 had half a vCPU and 8 GB, and g1t-2core a custom 2 vCPU, 8 GiB and 20 GB, where standard-3 had 16 GB; g1t-4core stays standard-4, which already had 20 GB. 1 vCPU is the smallest a custom type may have, and Cloudflare bills CPU only as it is used, so the doubled vCPU costs only what a job uses of it. The contracts' instance table, its TypeScript mirror and the billing keeper's standard sandbox say the same sizes; the price scales, worked out as before from Cloudflare's list prices with every vCPU busy and the Durable Object behind each, are now 1.9 for g1t-2core and 3.4 for g1t-4core, against the larger standard machine, and the fallbacks for a sandbox second (33 millionths of a dollar with its vCPU busy) and a build second (16) follow it. A test holds wrangler.jsonc's sizes to the contracts', and another the scales to the list prices. The docs' machine tables, the larger machines' prices and the Cloudflare deploy guide say so.569 // 4 x 2.5 + 20 x 0.07 + 0.125 x 12.5 + 0.2 x 20 = 16.9625
570 assert!((micros - 16.9625).abs() < 1e-9, "{micros}");
571 // The Durable Object adds about 14% to the second it left out.
572 assert!((sandbox_base_micros(LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_DO_GB_SECOND) - 12.9625).abs() < 1e-9);
The Packages page names every registry it has573 // Too little use to say anything.
574 assert!(sandbox_second_micros(ContainerUsage { cpu_seconds: 1.0, memory_byte_seconds: GIB }, 1.0, 1.0, 1.0, 1.0).is_none());
575 }
576
577 #[test]
578 fn a_run_that_reports_its_cpu_is_priced_on_it() {
579 let base = sandbox_base_micros(LIST_MEMORY_GIB_SECOND, LIST_DISK_GB_SECOND, LIST_DO_GB_SECOND);
580 let vcpu = LIST_VCPU_SECOND * MICROS_PER_DOLLAR as f64;
Every sandbox on g1t's runners has 20 GB of disk, the most Cloudflare Containers give one, as much or more than a GitHub-hosted ubuntu-latest's 14 GB: the standard machine (ubuntu-latest and every other hosted label, and all other work) becomes a custom instance type of 1 vCPU, 4 GiB and 20 GB, where standard-1 had half a vCPU and 8 GB, and g1t-2core a custom 2 vCPU, 8 GiB and 20 GB, where standard-3 had 16 GB; g1t-4core stays standard-4, which already had 20 GB. 1 vCPU is the smallest a custom type may have, and Cloudflare bills CPU only as it is used, so the doubled vCPU costs only what a job uses of it. The contracts' instance table, its TypeScript mirror and the billing keeper's standard sandbox say the same sizes; the price scales, worked out as before from Cloudflare's list prices with every vCPU busy and the Durable Object behind each, are now 1.9 for g1t-2core and 3.4 for g1t-4core, against the larger standard machine, and the fallbacks for a sandbox second (33 millionths of a dollar with its vCPU busy) and a build second (16) follow it. A test holds wrangler.jsonc's sizes to the contracts', and another the scales to the list prices. The docs' machine tables, the larger machines' prices and the Cloudflare deploy guide say so.581 // A 10-minute cargo build that kept half its vCPU busy throughout.
The Packages page names every registry it has582 let heavy = run_cost(600, 300.0, base, vcpu);
Every sandbox on g1t's runners has 20 GB of disk, the most Cloudflare Containers give one, as much or more than a GitHub-hosted ubuntu-latest's 14 GB: the standard machine (ubuntu-latest and every other hosted label, and all other work) becomes a custom instance type of 1 vCPU, 4 GiB and 20 GB, where standard-1 had half a vCPU and 8 GB, and g1t-2core a custom 2 vCPU, 8 GiB and 20 GB, where standard-3 had 16 GB; g1t-4core stays standard-4, which already had 20 GB. 1 vCPU is the smallest a custom type may have, and Cloudflare bills CPU only as it is used, so the doubled vCPU costs only what a job uses of it. The contracts' instance table, its TypeScript mirror and the billing keeper's standard sandbox say the same sizes; the price scales, worked out as before from Cloudflare's list prices with every vCPU busy and the Durable Object behind each, are now 1.9 for g1t-2core and 3.4 for g1t-4core, against the larger standard machine, and the fallbacks for a sandbox second (33 millionths of a dollar with its vCPU busy) and a build second (16) follow it. A test holds wrangler.jsonc's sizes to the contracts', and another the scales to the list prices. The docs' machine tables, the larger machines' prices and the Cloudflare deploy guide say so.583 assert!((heavy - (600.0 * 12.9625 + 300.0 * 20.0)).abs() < 1e-6);
The Packages page names every registry it has584 // The same ten minutes, mostly idle, costs less.
585 let light = run_cost(600, 30.0, base, vcpu);
586 assert!(light < heavy);
587 // The average would have under-priced the heavy one.
588 let average = 600.0 * (base + 0.195 * vcpu);
589 assert!(average < heavy && average > light);
590 assert_eq!(run_cost(0, -1.0, base, vcpu), 0.0);
591 // A larger machine's base: its memory and disk, not its CPU.
592 assert!((base_scale(SANDBOX_GIB, SANDBOX_DISK_GB) - 1.0).abs() < 1e-12);
Every sandbox on g1t's runners has 20 GB of disk, the most Cloudflare Containers give one, as much or more than a GitHub-hosted ubuntu-latest's 14 GB: the standard machine (ubuntu-latest and every other hosted label, and all other work) becomes a custom instance type of 1 vCPU, 4 GiB and 20 GB, where standard-1 had half a vCPU and 8 GB, and g1t-2core a custom 2 vCPU, 8 GiB and 20 GB, where standard-3 had 16 GB; g1t-4core stays standard-4, which already had 20 GB. 1 vCPU is the smallest a custom type may have, and Cloudflare bills CPU only as it is used, so the doubled vCPU costs only what a job uses of it. The contracts' instance table, its TypeScript mirror and the billing keeper's standard sandbox say the same sizes; the price scales, worked out as before from Cloudflare's list prices with every vCPU busy and the Durable Object behind each, are now 1.9 for g1t-2core and 3.4 for g1t-4core, against the larger standard machine, and the fallbacks for a sandbox second (33 millionths of a dollar with its vCPU busy) and a build second (16) follow it. A test holds wrangler.jsonc's sizes to the contracts', and another the scales to the list prices. The docs' machine tables, the larger machines' prices and the Cloudflare deploy guide say so.593 assert!((base_scale(12.0, 20.0) - 2.54).abs() < 0.01, "{}", base_scale(12.0, 20.0));
594 assert!((base_scale(8.0, 20.0) - 1.77).abs() < 0.01, "{}", base_scale(8.0, 20.0));
595 // Each machine in the contracts, priced from its own memory and disk.
596 for instance in g1t_contracts::actions::INSTANCE_TYPES {
597 let scale = base_scale(instance.memory_gib, instance.disk_gb);
598 assert!(scale >= 1.0 && scale < instance.price_scale.max(1.0) + 1e-9, "{}: {scale}", instance.label);
599 }
The Packages page names every registry it has600 }
601
602 #[test]
603 fn a_billed_rate_is_the_median_of_the_charged_days() {
Merge remote-tracking branch 'origin/main' into workspace-chat604 let row = |quantity: f64, list_cost: f64| UsageRow {
The Packages page names every registry it has605 period_start: String::new(),
606 period_end: String::new(),
607 service: "Containers / Container Memory".into(),
608 unit: "Count".into(),
609 quantity,
Merge remote-tracking branch 'origin/main' into workspace-chat610 cost: list_cost,
611 list_cost,
The Packages page names every registry it has612 };
613 let rows = [row(100.0, 0.0), row(100.0, 0.0002), row(100.0, 0.00025), row(100.0, 0.00025)];
614 assert_eq!(billed_rate(&rows.iter().collect::<Vec<_>>()), Some(0.000_002_5));
615 assert_eq!(billed_rate(&[&row(5.0, 0.0)]), None);
616 }
617
618 #[test]
Merge remote-tracking branch 'origin/main' into workspace-chat619 fn a_rate_is_the_list_price_not_what_was_billed_past_the_included_amount() {
620 // 126,870 GiB-seconds, of which the 36,870 past the included 90,000
621 // were billed: $0.09 billed, $0.32 at list. The rate is the list's.
622 let row = UsageRow {
623 period_start: String::new(),
624 period_end: String::new(),
625 service: "Containers / Container Memory".into(),
626 unit: "GiB-seconds".into(),
627 quantity: 126_870.0,
628 cost: 0.092_175,
629 list_cost: 0.317_175,
630 };
631 assert!((billed_rate(&[&row]).unwrap() - 0.000_002_5).abs() < 1e-15);
632 // Billed with no list cost says nothing about the price.
633 assert_eq!(billed_rate(&[&UsageRow { list_cost: 0.0, ..row }]), None);
634 }
635
636 #[test]
The Packages page names every registry it has637 fn usage_rows_are_read_by_their_focus_names() {
638 let row = UsageRow::from_value(&json!({
639 "ServiceFamilyName": "Containers",
640 "ServiceName": "Memory",
641 "PricingUnit": "GiB-seconds",
642 "PricingQuantity": "1200.5",
643 "ContractedCost": 0.003,
Merge remote-tracking branch 'origin/main' into workspace-chat644 "ListCost": 0.0030,
The Packages page names every registry it has645 "ChargePeriodStart": "2026-10-01",
646 }))
647 .unwrap();
648 assert_eq!(row.service, "Containers / Memory");
649 assert_eq!(row.quantity, 1200.5);
650 assert_eq!(row.cost, 0.003);
Merge remote-tracking branch 'origin/main' into workspace-chat651 assert_eq!(row.list_cost, 0.003);
The Packages page names every registry it has652 assert!(UsageRow::from_value(&json!({ "nothing": 1 })).is_none());
653 }
654}

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