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 pays | 1 | //! Keeps every price current with what g1t actually pays. |
| 2 | //! | |
| 3 | //! g1t passes its own costs through, so a price is only right while the | |
| 4 | //! cost under it is. Two jobs keep them right, on the billing service's | |
| 5 | //! cron: | |
| 6 | //! | |
| 7 | //! - **Settling runs** (every 15 minutes). A model run is charged when it | |
| 8 | //! finishes at what the sandbox reported. Each of g1t's hosted runs goes | |
| 9 | //! through its AI Gateway, which prices every request at the provider's | |
| 10 | //! current rates and logs it with the run's session. Settling sums those | |
| 11 | //! logs and corrects the charge to the gateway's figure, with a | |
| 12 | //! correction on the statement. A run whose sandbox died before | |
| 13 | //! reporting is charged here instead of never. | |
| 14 | //! - **Checking costs** (daily). What Cloudflare billed g1t's account, from | |
| 15 | //! its usage API, is measured against how much was used: Containers | |
| 16 | //! against the seconds containers ran, Workers for Platforms per request | |
| 17 | //! and per CPU millisecond. When a measured cost moves, the price book | |
| 18 | //! moves with it, since each price is its cost plus a set markup, and | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 19 | //! 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 pays | 24 | |
| 25 | use g1t_contracts::billing::{EntryKind, MICROS_PER_DOLLAR, Price, PriceBook, PriceChange}; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 26 | |
| Prices keep themselves current with what g1t pays | 27 | use g1t_contracts::time::rfc3339; |
| 28 | use g1t_kit::now_ms; | |
| 29 | use serde::Deserialize; | |
| 30 | use serde_json::{Value, json}; | |
| 31 | use worker::{Env, Fetch, Headers, Method, Request, RequestInit, Result}; | |
| 32 | ||
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 33 | use crate::{Billing, RunRow}; |
| Prices keep themselves current with what g1t pays | 34 | |
| 35 | /// The cron that also checks costs against Cloudflare's bill. | |
| 36 | pub(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. |
| 38 | pub(crate) const QUARTER_HOURLY: &str = "*/15 * * * *"; | |
| Prices keep themselves current with what g1t pays | 39 | |
| 40 | /// A run is settled once its logs have had time to land. | |
| 41 | const SETTLE_AFTER_MS: u64 = 5 * 60 * 1000; | |
| 42 | /// A run with no gateway logs after this is left as reported. | |
| 43 | const GIVE_UP_AFTER_MS: u64 = 3 * 60 * 60 * 1000; | |
| 44 | /// A run never finished after this died without reporting. | |
| 45 | const ABANDONED_AFTER_MS: u64 = 3 * 60 * 60 * 1000; | |
| 46 | ||
| 47 | /// Where the keeper reads what g1t pays. | |
| 48 | pub(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 daily | 52 | /// 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 pays | 56 | account: String, |
| 57 | gateway: String, | |
| 58 | } | |
| 59 | ||
| 60 | impl 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 daily | 63 | 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 pays | 65 | Keeper { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 66 | billing_token: secret("CLOUDFLARE_BILLING_TOKEN").or_else(|| token.clone()), |
| 67 | token, | |
| Prices keep themselves current with what g1t pays | 68 | 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 daily | 77 | 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 pays | 96 | } |
| 97 | ||
| Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing | 98 | /// 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 daily | 104 | /// 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 pays | 113 | 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 said | 122 | /// 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 rises | 123 | /// (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 said | 128 | 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 said | 134 | 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 rises | 141 | /// 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 answers | 158 | // 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 pays | 164 | let url = self.api(&format!( |
| The keeper reads Cloudflare as it really answers | 165 | "/ai-gateway/gateways/{}/logs?per_page=50&page={page}&filters={filter}", |
| Prices keep themselves current with what g1t pays | 166 | 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 pays | 172 | } |
| 173 | if logs.len() < 50 { | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 174 | return Ok(total); |
| Prices keep themselves current with what g1t pays | 175 | } |
| 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 pays | 180 | } |
| 181 | ||
| The keeper reads Cloudflare as it really answers | 182 | /// The account's billable usage, one row per service per day, as |
| 183 | /// Cloudflare reports it. | |
| Prices keep themselves current with what g1t pays | 184 | 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 pays | 189 | 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 answers | 193 | /// 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 pays | 197 | viewer { accounts(filter: { accountTag: $account }) { |
| The keeper reads Cloudflare as it really answers | 198 | containersUsageAdaptiveGroups(limit: 1000, filter: { date_geq: $since, date_leq: $until }) { |
| 199 | sum { cpuTimeSec allocatedMemory } | |
| Prices keep themselves current with what g1t pays | 200 | } |
| 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 answers | 210 | let groups = body["data"]["viewer"]["accounts"][0]["containersUsageAdaptiveGroups"] |
| Prices keep themselves current with what g1t pays | 211 | .as_array() |
| 212 | .cloned() | |
| 213 | .unwrap_or_default(); | |
| The keeper reads Cloudflare as it really answers | 214 | 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 pays | 218 | } |
| 219 | } | |
| 220 | ||
| Merge costs and margin review: gateway query, own spend, discount meters, superseded rises | 221 | /// 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. | |
| 224 | pub(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 daily | 228 | /// 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. | |
| 232 | const 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. | |
| 236 | async 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 daily | 255 | async 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 answers | 273 | #[derive(Debug, Default, Clone, Copy)] |
| 274 | pub(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. | |
| 280 | const SANDBOX_GIB: f64 = 4.0; | |
| 281 | const SANDBOX_DISK_GB: f64 = 8.0; | |
| 282 | const GIB: f64 = 1024.0 * 1024.0 * 1024.0; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 283 | /// The Durable Object behind each container is billed for as long as the |
| 284 | /// container runs, at 128 MB. | |
| 285 | const SANDBOX_DO_GB: f64 = 0.125; | |
| The keeper reads Cloudflare as it really answers | 286 | |
| 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). | |
| 289 | const LIST_MEMORY_GIB_SECOND: f64 = 0.000_002_5; | |
| 290 | const LIST_DISK_GB_SECOND: f64 = 0.000_000_07; | |
| 291 | const LIST_VCPU_SECOND: f64 = 0.000_02; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 292 | /// Durable Objects duration: $12.50 per million GB-seconds. |
| 293 | const LIST_DO_GB_SECOND: f64 = 0.000_012_5; | |
| The keeper reads Cloudflare as it really answers | 294 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 295 | /// 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. | |
| 298 | pub(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`). | |
| 306 | pub(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 answers | 307 | 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 look | 312 | 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 incidents | 315 | /// 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. | |
| 318 | pub(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 look | 323 | /// What a run that reported its own CPU cost g1t: its base for every |
| 324 | /// second, and its vCPU-seconds at the vCPU rate. | |
| 325 | pub(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 answers | 327 | } |
| 328 | ||
| Merge remote-tracking branch 'origin/main' into workspace-chat | 329 | /// 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 answers | 335 | pub(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-chat | 338 | .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 answers | 340 | .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 pays | 348 | /// One line of Cloudflare's billable usage. |
| 349 | #[derive(Debug, Clone)] | |
| 350 | pub(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-chat | 357 | /// The quantity at list price, before the included amounts. |
| 358 | list_cost: f64, | |
| Prices keep themselves current with what g1t pays | 359 | } |
| 360 | ||
| 361 | impl 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 answers | 379 | unit: text(&["PricingUnit", "ConsumedUnit", "consumed_unit"]), |
| Prices keep themselves current with what g1t pays | 380 | quantity: number(&["PricingQuantity", "ConsumedQuantity", "pricing_quantity"]), |
| The keeper reads Cloudflare as it really answers | 381 | // 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-chat | 385 | list_cost: number(&["ListCost", "list_cost"]), |
| Prices keep themselves current with what g1t pays | 386 | }) |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | #[derive(Deserialize)] | |
| 391 | struct 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)] | |
| 403 | struct 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 second | 408 | old_markup_percent: Option<u32>, |
| Prices keep themselves current with what g1t pays | 409 | reason: String, |
| 410 | created_at: String, | |
| 411 | } | |
| 412 | ||
| 413 | #[derive(Deserialize)] | |
| 414 | struct 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 pays | 430 | } |
| 431 | ||
| 432 | #[derive(Deserialize)] | |
| 433 | struct 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 put | 436 | 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. | |
| 444 | pub(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 pays | 447 | } |
| 448 | ||
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 449 | /// A session's logs are read 50 at a time, up to this many pages. |
| 450 | const MAX_LOG_PAGES: u32 = 40; | |
| 451 | ||
| 452 | /// What AI Gateway's logs say a session cost. | |
| 453 | #[derive(Clone, Debug, Default, PartialEq)] | |
| 454 | pub(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 | ||
| 467 | impl 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. | |
| 502 | pub(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 pays | 523 | fn ms(timestamp: &str) -> u64 { |
| 524 | // RFC 3339 in UTC, as g1t writes them. | |
| 525 | worker::js_sys::Date::parse(timestamp) as u64 | |
| 526 | } | |
| 527 | ||
| 528 | impl 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 index | 530 | // 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 audit | 546 | 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 index | 547 | 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 person | 551 | // 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 bar | 553 | let plans: Vec<_> = g1t_contracts::billing::Feature::ALL |
| 554 | .iter() | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 555 | .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 bar | 562 | }) |
| 563 | .collect(); | |
| Prices keep themselves current with what g1t pays | 564 | 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 daily | 579 | changes: coming |
| Prices keep themselves current with what g1t pays | 580 | .into_iter() |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 581 | .chain(changes.into_iter().map(|row| PriceChange { |
| Prices keep themselves current with what g1t pays | 582 | 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 second | 586 | old_markup_percent: row.old_markup_percent, |
| Prices keep themselves current with what g1t pays | 587 | reason: row.reason, |
| 588 | created_at: row.created_at, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 589 | effective_at: None, |
| 590 | })) | |
| Prices keep themselves current with what g1t pays | 591 | .collect(), |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 592 | // 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 daily | 595 | plans, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 596 | 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 sudo | 602 | 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 put | 604 | min_charge_micros: self.plans.min_charge_micros, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 605 | 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 put | 608 | }), |
| Prices keep themselves current with what g1t pays | 609 | }) |
| 610 | } | |
| 611 | ||
| The keeper reads Cloudflare as it really answers | 612 | /// 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 pays | 622 | /// 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 pays | 649 | 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 pays | 651 | 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 pays | 660 | 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 pays | 668 | continue; |
| 669 | } | |
| Merge branch 'worktree-agent-a633ac0f7f66d419d' | 670 | self.settle(&run, &gateway).await?; |
| Prices keep themselves current with what g1t pays | 671 | } |
| 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 pays | 741 | 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 pays | 751 | }; |
| 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 put | 754 | .prepare("SELECT cost_micros, description, amount_micros FROM ledger WHERE reference = ?") |
| Prices keep themselves current with what g1t pays | 755 | .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 free | 762 | let terms = self.terms_of(&run.workspace).await?; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 763 | // 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 audit | 767 | // below cost plus the markup (counted as given, see `charged`). |
| Prices keep themselves current with what g1t pays | 768 | let charge_for = |micros: i64| { |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 769 | if self.free { (0, 0) } else { terms.discounted(crate::margin_on(micros, markup)) } |
| Prices keep themselves current with what g1t pays | 770 | }; |
| 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 pays | 779 | .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 pays | 783 | 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 audit | 791 | // 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 pays | 798 | 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 look | 803 | 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 put | 804 | let drawn = self.draw(&run.workspace, charge, &settled_at[..7], &eligible).await?; |
| Prices keep themselves current with what g1t pays | 805 | 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 put | 807 | run.repo, |
| 808 | run.number, | |
| 809 | drawn.note() | |
| Prices keep themselves current with what g1t pays | 810 | ); |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 811 | 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 pays | 812 | .await?; |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 813 | 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 pays | 815 | self.count_spend(&run.workspace, gateway_micros, charge - drawn.total(), &drawn).await; |
| Prices keep themselves current with what g1t pays | 816 | } |
| 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 put | 826 | // 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 look | 828 | 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 put | 829 | 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 pays | 833 | 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 pays | 835 | 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 put | 838 | drawn.note(), |
| Prices keep themselves current with what g1t pays | 839 | ); |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 840 | let reference = format!("{}/settled", run.id); |
| Prices keep themselves current with what g1t pays | 841 | 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 put | 844 | -(change - drawn.total()), |
| Prices keep themselves current with what g1t pays | 845 | &description, |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 846 | &reference, |
| Prices keep themselves current with what g1t pays | 847 | 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 put | 853 | 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 pays | 855 | self.count_spend(&run.workspace, delta, change - drawn.total(), &drawn).await; |
| Prices keep themselves current with what g1t pays | 856 | } |
| 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 answers | 869 | 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 pays | 871 | 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 answers | 892 | let named = |words: &[&str]| -> Vec<&UsageRow> { |
| Prices keep themselves current with what g1t pays | 893 | rows.iter() |
| The keeper reads Cloudflare as it really answers | 894 | .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 pays | 899 | }; |
| 900 | ||
| Merge remote-tracking branch 'origin/main' into workspace-chat | 901 | // 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 answers | 903 | // 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 look | 907 | let durable_object = billed_rate(&named(&["durable objects", "duration"])); |
| The keeper reads Cloudflare as it really answers | 908 | let usage = keeper.container_usage(&since[..10], today).await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 909 | let rates = ( |
| The keeper reads Cloudflare as it really answers | 910 | 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 look | 913 | 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-chat | 916 | 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 look | 917 | 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 answers | 920 | 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 look | 921 | let billed = [("memory", memory), ("disk", disk), ("vCPU", vcpu), ("Durable Object duration", durable_object)] |
| The keeper reads Cloudflare as it really answers | 922 | .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 look | 927 | "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 answers | 928 | 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-chat | 933 | format!("{} at the list cost on Cloudflare's bill", billed.join(", ")) |
| The keeper reads Cloudflare as it really answers | 934 | }, |
| 935 | ); | |
| 936 | for meter in ["sandbox_second", "build_second"] { | |
| 937 | self.measure(meter, per_second, &reason).await?; | |
| Prices keep themselves current with what g1t pays | 938 | } |
| 939 | } | |
| The keeper reads Cloudflare as it really answers | 940 | // 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 put | 942 | // Security scans' CPU follows the same Workers CPU rate. |
| 943 | let app_meters: [(&str, &[&str], &str); 3] = [ | |
| The keeper reads Cloudflare as it really answers | 944 | ("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 put | 946 | ("scan_cpu", &["workers cpu"], "CPU ms"), |
| The keeper reads Cloudflare as it really answers | 947 | ]; |
| 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-chat | 950 | let reason = format!("Cloudflare's bill lists Workers {unit} at ${:.2} per million", rate * 1e6); |
| The keeper reads Cloudflare as it really answers | 951 | self.measure(meter, rate * 1e6 * MICROS_PER_DOLLAR as f64, &reason).await?; |
| Prices keep themselves current with what g1t pays | 952 | } |
| 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 daily | 962 | /// 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 pays | 965 | 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 daily | 966 | if let Some(outcome) = self.propose(meter, measured, reason, "keeper").await? { |
| 967 | worker::console_log!("{meter}: {outcome}"); | |
| Prices keep themselves current with what g1t pays | 968 | } |
| 969 | Ok(()) | |
| 970 | } | |
| 971 | } | |
| 972 | ||
| 973 | #[cfg(test)] | |
| 974 | mod 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 put | 978 | 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 pays | 987 | } |
| 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 pays | 1057 | #[test] |
| The keeper reads Cloudflare as it really answers | 1058 | 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 look | 1061 | 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 answers | 1067 | // Too little use to say anything. |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1068 | 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 incidents | 1085 | // 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 answers | 1089 | } |
| 1090 | ||
| 1091 | #[test] | |
| 1092 | fn a_billed_rate_is_the_median_of_the_charged_days() { | |
| Merge remote-tracking branch 'origin/main' into workspace-chat | 1093 | let row = |quantity: f64, list_cost: f64| UsageRow { |
| The keeper reads Cloudflare as it really answers | 1094 | 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-chat | 1099 | cost: list_cost, |
| 1100 | list_cost, | |
| The keeper reads Cloudflare as it really answers | 1101 | }; |
| 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-chat | 1108 | 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 pays | 1126 | 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 answers | 1130 | "PricingUnit": "GiB-seconds", |
| Prices keep themselves current with what g1t pays | 1131 | "PricingQuantity": "1200.5", |
| 1132 | "ContractedCost": 0.003, | |
| Merge remote-tracking branch 'origin/main' into workspace-chat | 1133 | "ListCost": 0.0030, |
| Prices keep themselves current with what g1t pays | 1134 | "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-chat | 1140 | assert_eq!(row.list_cost, 0.003); |
| Prices keep themselves current with what g1t pays | 1141 | 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.