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.
| Fixes from the page sweep | 1 | //! What g1t pays for itself, and two caps on it. |
| 2 | //! | |
| 3 | //! Every charge that settles (an agent run, sandbox time, a build) is | |
| 4 | //! split by what paid for it, at cost: a customer's real money, or g1t's. | |
| 5 | //! g1t's part goes to `g1t_spend` by day, bucket and billing account: | |
| 6 | //! | |
| 7 | //! - `comped`: work on a comped account (g1t's own, Flagon's), all of it. | |
| 8 | //! - `trial`, `oss`: the trial credit and the open-source pool. | |
| 9 | //! - `given`: a free workspace's overrun past its last bit of trial. | |
| 10 | //! - `unpaid`: charged, but with no real money behind it: Stripe's test | |
| 11 | //! key, or `FREE_WHILE_BUILDING`. | |
| 12 | //! | |
| 13 | //! The plan's included usage and on-demand charges with live payments are | |
| 14 | //! revenue, not g1t's. A workspace's own model provider costs g1t nothing. | |
| 15 | //! | |
| 16 | //! Two caps read it: | |
| 17 | //! | |
| 18 | //! 1. **A comped account's monthly budget**: `COMPED_MONTHLY_CEILING_MICROS` | |
| 19 | //! ($150), or the account's own limit in its terms (sudo, Accounts → | |
| 20 | //! Terms → Limit). Staff are emailed at 50, 75, 90 and 100%, once each a | |
| 21 | //! month; at 100% new work on it is refused until staff raise it or the | |
| 22 | //! month turns. Work already running finishes. | |
| 23 | //! 2. **The daily breaker**: when g1t's part across every workspace today | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 24 | //! (UTC) reaches `PLATFORM_DAILY_SPEND_CAP_MICROS` ($75), new compute |
| 25 | //! that g1t would pay for (sandboxes for workflows, checks, the merge | |
| 26 | //! queue and agents alike) is paused for the rest of the day: everyone's | |
| 27 | //! except workspaces paying with real money on the plan or an enterprise | |
| 28 | //! contract, and comped accounts, which have their own monthly budget. Staff are emailed at once and sudo | |
| Fixes from the page sweep | 29 | //! shows a red bar; staff can lift it for the day. |
| 30 | //! | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 31 | //! Zero for either variable turns that cap off. |
| Fixes from the page sweep | 32 | |
| 33 | use g1t_contracts::billing::{ | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 34 | AdminLiftBreakerArgs, BillingAccount, CompedBudget, ComputeKind, PlanKind, SpendBucket, SpendCaps, |
| Fixes from the page sweep | 35 | }; |
| 36 | use g1t_contracts::time::rfc3339; | |
| 37 | use g1t_contracts::{FailureCode, Outcome}; | |
| 38 | use g1t_kit::now_ms; | |
| 39 | use serde::Deserialize; | |
| 40 | use worker::{Env, Result}; | |
| 41 | ||
| 42 | use crate::Billing; | |
| 43 | use crate::credits::Drawn; | |
| 44 | use crate::limits::alert_level; | |
| 45 | ||
| 46 | /// The caps, from the billing service's variables. | |
| 47 | #[derive(Clone, Debug)] | |
| 48 | pub(crate) struct Caps { | |
| 49 | /// `COMPED_MONTHLY_CEILING_MICROS`: a comped account's monthly budget | |
| 50 | /// at cost, unless its terms set one. Zero: none. | |
| 51 | pub comped_monthly: i64, | |
| 52 | /// `PLATFORM_DAILY_SPEND_CAP_MICROS`: g1t's own spend a day before the | |
| 53 | /// breaker trips. Zero: no breaker. | |
| 54 | pub daily: i64, | |
| 55 | /// `CLOUDFLARE_FIXED_MONTHLY_MICROS`: Cloudflare's subscriptions, an | |
| 56 | /// estimate for sudo. | |
| 57 | pub fixed_monthly: i64, | |
| 58 | /// `COSTS_ALERT_EMAIL`. Empty: nothing is emailed. | |
| 59 | pub alert_to: String, | |
| 60 | } | |
| 61 | ||
| 62 | impl Caps { | |
| 63 | pub(crate) fn from_env(env: &Env) -> Self { | |
| 64 | let number = |name: &str, default: i64| { | |
| 65 | env.var(name).ok().and_then(|v| v.to_string().trim().parse::<i64>().ok()).unwrap_or(default).max(0) | |
| 66 | }; | |
| 67 | Caps { | |
| 68 | comped_monthly: number("COMPED_MONTHLY_CEILING_MICROS", 150_000_000), | |
| 69 | daily: number("PLATFORM_DAILY_SPEND_CAP_MICROS", 75_000_000), | |
| 70 | fixed_monthly: number("CLOUDFLARE_FIXED_MONTHLY_MICROS", 30_000_000), | |
| 71 | alert_to: env.var("COSTS_ALERT_EMAIL").map(|v| v.to_string().trim().to_owned()).unwrap_or_default(), | |
| 72 | } | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | /// g1t's part of one charge, at cost, by what paid for it. | |
| 77 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 78 | pub(crate) struct Share { | |
| 79 | pub comped: i64, | |
| 80 | pub trial: i64, | |
| 81 | pub oss: i64, | |
| 82 | pub given: i64, | |
| 83 | pub unpaid: i64, | |
| 84 | } | |
| 85 | ||
| 86 | impl Share { | |
| 87 | pub fn total(&self) -> i64 { | |
| 88 | self.comped + self.trial + self.oss + self.given + self.unpaid | |
| 89 | } | |
| 90 | ||
| 91 | pub fn parts(&self) -> [(&'static str, i64); 5] { | |
| 92 | [("comped", self.comped), ("trial", self.trial), ("oss", self.oss), ("given", self.given), ("unpaid", self.unpaid)] | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | /// What of a charge costing g1t `cost` g1t paid itself. `charged` is what | |
| 97 | /// the workspace was charged after `drawn` paid its part (both at price); | |
| 98 | /// `real_money` is whether payments are live. The plan's included usage | |
| 99 | /// and what the workspace is charged are revenue only with real money. | |
| 100 | pub(crate) fn share(cost: i64, charged: i64, drawn: &Drawn, comped: bool, real_money: bool) -> Share { | |
| 101 | if cost <= 0 { | |
| 102 | return Share::default(); | |
| 103 | } | |
| 104 | if comped { | |
| 105 | return Share { comped: cost, ..Share::default() }; | |
| 106 | } | |
| 107 | let gross = charged.max(0) + drawn.total(); | |
| 108 | if gross <= 0 { | |
| 109 | // Charged nothing at all (free while g1t is being built out). | |
| 110 | return Share { unpaid: cost, ..Share::default() }; | |
| 111 | } | |
| 112 | let part = |paid: i64| (i128::from(cost) * i128::from(paid.max(0)) / i128::from(gross)) as i64; | |
| 113 | let (trial, oss, given) = (part(drawn.trial), part(drawn.oss), part(drawn.given)); | |
| 114 | let unpaid = if real_money { 0 } else { (cost - trial - oss - given).max(0) }; | |
| 115 | Share { comped: 0, trial, oss, given, unpaid } | |
| 116 | } | |
| 117 | ||
| 118 | /// A comped account's monthly budget: its own (terms' limit) or the | |
| 119 | /// default; and whether it is the default. Zero: none. | |
| 120 | pub(crate) fn comped_ceiling(own: Option<i64>, default: i64) -> (i64, bool) { | |
| 121 | match own { | |
| 122 | Some(own) => (own.max(0), false), | |
| 123 | None => (default.max(0), true), | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | /// Whether a budget is used up. | |
| 128 | pub(crate) fn used_up(used: i64, ceiling: i64) -> bool { | |
| 129 | ceiling > 0 && used >= ceiling | |
| 130 | } | |
| 131 | ||
| 132 | /// Whether the breaker stops new runs: on, reached, and not lifted today. | |
| 133 | pub(crate) fn breaker_open(today: i64, cap: i64, lifted: bool) -> bool { | |
| 134 | cap > 0 && today >= cap && !lifted | |
| 135 | } | |
| 136 | ||
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 137 | /// Whether the breaker is about this start: any compute g1t would pay |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 138 | /// for. Every kind costs g1t sandbox time, so none is left out; a |
| 139 | /// workspace whose spend is covered is left out by [`covered_by_revenue`] | |
| 140 | /// instead. | |
| 141 | pub(crate) fn breaker_applies(_kind: ComputeKind) -> bool { | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 142 | true |
| Fixes from the page sweep | 143 | } |
| 144 | ||
| 145 | /// Whether a workspace's spend is covered by revenue, so the breaker | |
| 146 | /// leaves it alone: live payments, not comped, and on the plan it pays for | |
| 147 | /// (not given it by staff) or an enterprise contract. | |
| 148 | pub(crate) fn covered_by_revenue(plan: PlanKind, comped: bool, plan_given: bool, live: bool) -> bool { | |
| 149 | live && !comped && match plan { | |
| 150 | PlanKind::Enterprise => true, | |
| 151 | PlanKind::Paid => !plan_given, | |
| 152 | _ => false, | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | /// The alert to send now: the level reached, if higher than any sent this | |
| 157 | /// month. | |
| 158 | pub(crate) fn alert_to_send(level: u32, sent: u32) -> Option<u32> { | |
| 159 | (level > 0 && level > sent).then_some(level) | |
| 160 | } | |
| 161 | ||
| 162 | /// `$150.00`: whole cents. | |
| 163 | pub(crate) fn cents(micros: i64) -> String { | |
| 164 | let cents = (micros as f64 / 10_000.0).round() as i64; | |
| 165 | format!("{}${}.{:02}", if cents < 0 { "-" } else { "" }, cents.abs() / 100, cents.abs() % 100) | |
| 166 | } | |
| 167 | ||
| 168 | /// What a start on a comped account past its budget is told. Staff-only: | |
| 169 | /// only comped (g1t's own) accounts see it. | |
| 170 | pub(crate) fn comped_refusal(name: &str, used: i64, ceiling: i64) -> String { | |
| 171 | format!( | |
| 172 | "{name}'s monthly budget for g1t's own agents is used up ({} of {} this month at cost), so new runs wait. Staff can raise it in sudo: Accounts, {name}, Terms, Limit.", | |
| 173 | cents(used), | |
| 174 | cents(ceiling) | |
| 175 | ) | |
| 176 | } | |
| 177 | ||
| 178 | /// What a hosted-model start is told while the breaker is open. | |
| 179 | pub(crate) fn breaker_refusal(today: i64, cap: i64) -> String { | |
| 180 | format!( | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 181 | "g1t's daily spend breaker is open: g1t has paid {} of its {} a day for work today, so new runs g1t pays for wait until 00:00 UTC. Work on the paid plan still runs.", |
| Fixes from the page sweep | 182 | cents(today), |
| 183 | cents(cap) | |
| 184 | ) | |
| 185 | } | |
| 186 | ||
| 187 | #[derive(Deserialize)] | |
| 188 | struct Sum { | |
| 189 | micros: Option<i64>, | |
| 190 | } | |
| 191 | ||
| 192 | #[derive(Deserialize)] | |
| 193 | struct BreakerRow { | |
| 194 | tripped_at: Option<String>, | |
| 195 | told_at: Option<String>, | |
| 196 | lifted_by: Option<String>, | |
| 197 | lifted_at: Option<String>, | |
| 198 | lift_note: Option<String>, | |
| 199 | } | |
| 200 | ||
| 201 | fn today() -> String { | |
| 202 | rfc3339(now_ms())[..10].to_owned() | |
| 203 | } | |
| 204 | ||
| 205 | impl Billing { | |
| 206 | fn live(&self) -> bool { | |
| 207 | self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) | |
| 208 | } | |
| 209 | ||
| 210 | /// Counts g1t's part of a charge that just settled, and trips the | |
| 211 | /// breaker if today reached its cap. Never fails the charge: a problem | |
| 212 | /// here is logged. | |
| 213 | pub(crate) async fn count_spend(&self, workspace: &str, cost: i64, charged: i64, drawn: &Drawn) { | |
| 214 | if let Err(error) = self.try_count_spend(workspace, cost, charged, drawn).await { | |
| 215 | worker::console_error!("could not count g1t's spend for {workspace}: {error}"); | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | async fn try_count_spend(&self, workspace: &str, cost: i64, charged: i64, drawn: &Drawn) -> Result<()> { | |
| 220 | if cost <= 0 { | |
| 221 | return Ok(()); | |
| 222 | } | |
| 223 | let account = self.account_of(workspace).await?; | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 224 | let paid = share(cost, charged, drawn, account.terms.full_discount(), self.live()); |
| Fixes from the page sweep | 225 | if paid.total() == 0 { |
| 226 | return Ok(()); | |
| 227 | } | |
| 228 | let day = today(); | |
| 229 | let mut writes = vec![]; | |
| 230 | for (bucket, micros) in paid.parts() { | |
| 231 | if micros > 0 { | |
| 232 | writes.push( | |
| 233 | self.db | |
| 234 | .prepare( | |
| 235 | "INSERT INTO g1t_spend (day, bucket, account, micros) VALUES (?1, ?2, ?3, ?4) | |
| 236 | ON CONFLICT (day, bucket, account) DO UPDATE SET micros = micros + ?4", | |
| 237 | ) | |
| 238 | .bind(&[day.as_str().into(), bucket.into(), account.id.as_str().into(), (micros as f64).into()])?, | |
| 239 | ); | |
| 240 | } | |
| 241 | } | |
| 242 | self.db.batch(writes).await?; | |
| 243 | if self.caps.daily <= 0 { | |
| 244 | return Ok(()); | |
| 245 | } | |
| 246 | let total = self.spent_on(&day).await?; | |
| 247 | if total < self.caps.daily { | |
| 248 | return Ok(()); | |
| 249 | } | |
| 250 | // Tripped: recorded once a day, and staff told at once. | |
| 251 | let now = rfc3339(now_ms()); | |
| 252 | let tripped = self | |
| 253 | .db | |
| 254 | .prepare( | |
| 255 | "INSERT INTO spend_breaker (day, tripped_at, tripped_micros) VALUES (?1, ?2, ?3) | |
| 256 | ON CONFLICT (day) DO UPDATE SET tripped_at = ?2, tripped_micros = ?3 WHERE spend_breaker.tripped_at IS NULL | |
| 257 | RETURNING day", | |
| 258 | ) | |
| 259 | .bind(&[day.as_str().into(), now.as_str().into(), (total as f64).into()])? | |
| 260 | .first::<serde_json::Value>(None) | |
| 261 | .await?; | |
| 262 | if tripped.is_some() { | |
| 263 | self.tell_breaker(&day, total).await?; | |
| 264 | } | |
| 265 | Ok(()) | |
| 266 | } | |
| 267 | ||
| 268 | /// g1t's own spend on `day`, across every workspace. | |
| 269 | async fn spent_on(&self, day: &str) -> Result<i64> { | |
| 270 | Ok(self | |
| 271 | .db | |
| 272 | .prepare("SELECT SUM(micros) AS micros FROM g1t_spend WHERE day = ?") | |
| 273 | .bind(&[day.into()])? | |
| 274 | .first::<Sum>(None) | |
| 275 | .await? | |
| 276 | .and_then(|s| s.micros) | |
| 277 | .unwrap_or(0)) | |
| 278 | } | |
| 279 | ||
| 280 | async fn breaker_row(&self, day: &str) -> Result<Option<BreakerRow>> { | |
| 281 | self.db | |
| 282 | .prepare("SELECT tripped_at, told_at, lifted_by, lifted_at, lift_note FROM spend_breaker WHERE day = ?") | |
| 283 | .bind(&[day.into()])? | |
| 284 | .first::<BreakerRow>(None) | |
| 285 | .await | |
| 286 | } | |
| 287 | ||
| 288 | /// Emails staff that the breaker tripped, and notes it was told. | |
| 289 | async fn tell_breaker(&self, day: &str, total: i64) -> Result<()> { | |
| 290 | if self.caps.alert_to.is_empty() { | |
| 291 | return Ok(()); | |
| 292 | } | |
| 293 | let lifted = self.breaker_row(day).await?.and_then(|row| row.lifted_by); | |
| 294 | let mut lines = vec![ | |
| 295 | format!( | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 296 | "g1t paid {} for work today ({day}, UTC), its daily cap of {} (PLATFORM_DAILY_SPEND_CAP_MICROS). New compute that g1t pays for (workflows, checks, the merge queue and other runs) is paused until 00:00 UTC; workspaces paying with real money, and comped accounts, are not affected. Runs already going finish.", |
| Fixes from the page sweep | 297 | cents(total), |
| 298 | cents(self.caps.daily) | |
| 299 | ), | |
| 300 | "To let them start again today: sudo, Costs & margin, Lift for today. To change the cap: PLATFORM_DAILY_SPEND_CAP_MICROS in services/billing/wrangler.jsonc.".to_owned(), | |
| 301 | ]; | |
| 302 | if let Some(by) = lifted { | |
| 303 | lines.insert(1, format!("{by} had already lifted it for today, so nothing is paused.")); | |
| 304 | } | |
| 305 | match crate::margin::email_staff(&self.env, &self.caps.alert_to, &format!("g1t: the daily spend breaker tripped at {}", cents(total)), &lines).await { | |
| 306 | Ok(()) => { | |
| 307 | self.db | |
| 308 | .prepare("UPDATE spend_breaker SET told_at = ? WHERE day = ?") | |
| 309 | .bind(&[rfc3339(now_ms()).into(), day.into()])? | |
| 310 | .run() | |
| 311 | .await?; | |
| 312 | } | |
| 313 | Err(error) => worker::console_error!("could not email the breaker: {error}"), | |
| 314 | } | |
| 315 | Ok(()) | |
| 316 | } | |
| 317 | ||
| 318 | /// Why a start is refused by the breaker, if it is. | |
| 319 | pub(crate) async fn breaker_refuses( | |
| 320 | &self, | |
| 321 | plan: PlanKind, | |
| 322 | account: &BillingAccount, | |
| 323 | kind: ComputeKind, | |
| 324 | ) -> Result<Option<String>> { | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 325 | if self.caps.daily <= 0 || !breaker_applies(kind) { |
| Fixes from the page sweep | 326 | return Ok(None); |
| 327 | } | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 328 | let comped = account.terms.full_discount(); |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 329 | // A comped account is held by its own monthly budget instead. |
| 330 | if comped || covered_by_revenue(plan, comped, account.allowances.plan, self.live()) { | |
| Fixes from the page sweep | 331 | return Ok(None); |
| 332 | } | |
| 333 | let day = today(); | |
| 334 | let spent = self.spent_on(&day).await?; | |
| 335 | if spent < self.caps.daily { | |
| 336 | return Ok(None); | |
| 337 | } | |
| 338 | let lifted = self.breaker_row(&day).await?.is_some_and(|row| row.lifted_at.is_some()); | |
| 339 | Ok(breaker_open(spent, self.caps.daily, lifted).then(|| breaker_refusal(spent, self.caps.daily))) | |
| 340 | } | |
| 341 | ||
| 342 | /// A comped account's budget this month. | |
| 343 | pub(crate) async fn comped_budget(&self, account: &BillingAccount) -> Result<CompedBudget> { | |
| 344 | let month = &rfc3339(now_ms())[..7]; | |
| 345 | let used = self | |
| 346 | .db | |
| 347 | .prepare("SELECT SUM(micros) AS micros FROM g1t_spend WHERE account = ? AND bucket = 'comped' AND day >= ?") | |
| 348 | .bind(&[account.id.as_str().into(), format!("{month}-01").into()])? | |
| 349 | .first::<Sum>(None) | |
| 350 | .await? | |
| 351 | .and_then(|s| s.micros) | |
| 352 | .unwrap_or(0); | |
| 353 | let (ceiling, default_ceiling) = comped_ceiling(account.terms.ceiling_micros, self.caps.comped_monthly); | |
| 354 | Ok(CompedBudget { | |
| 355 | account: account.id.clone(), | |
| 356 | name: account.name.clone(), | |
| 357 | used_micros: used, | |
| 358 | ceiling_micros: ceiling, | |
| 359 | default_ceiling, | |
| 360 | level: alert_level(used, ceiling), | |
| 361 | }) | |
| 362 | } | |
| 363 | ||
| 364 | /// Why new work on a comped account is refused, if its budget is used | |
| 365 | /// up. None for every other account. | |
| 366 | pub(crate) async fn comped_stop(&self, account: &BillingAccount) -> Result<Option<String>> { | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 367 | if !account.terms.full_discount() { |
| Fixes from the page sweep | 368 | return Ok(None); |
| 369 | } | |
| 370 | let budget = self.comped_budget(account).await?; | |
| 371 | Ok(used_up(budget.used_micros, budget.ceiling_micros).then(|| comped_refusal(&account.name, budget.used_micros, budget.ceiling_micros))) | |
| 372 | } | |
| 373 | ||
| 374 | /// Every 15 minutes: comped budgets' alerts, once each level a month, | |
| 375 | /// and a tripped breaker staff were not yet told about. | |
| 376 | pub(crate) async fn watch_spend(&self) -> Result<()> { | |
| 377 | if self.caps.alert_to.is_empty() { | |
| 378 | return Ok(()); | |
| 379 | } | |
| 380 | let month = rfc3339(now_ms())[..7].to_owned(); | |
| 381 | #[derive(Deserialize)] | |
| 382 | struct Id { | |
| 383 | id: String, | |
| 384 | } | |
| 385 | let comped = self | |
| 386 | .db | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 387 | .prepare(format!("SELECT id FROM billing_accounts WHERE {}", crate::sales::FULL_DISCOUNT_SQL)) |
| Fixes from the page sweep | 388 | .all() |
| 389 | .await? | |
| 390 | .results::<Id>()?; | |
| 391 | #[derive(Deserialize)] | |
| 392 | struct Sent { | |
| 393 | level: Option<i64>, | |
| 394 | } | |
| 395 | for Id { id } in comped { | |
| 396 | let Some(account) = self.find_account(&id).await? else { continue }; | |
| 397 | let budget = self.comped_budget(&account).await?; | |
| 398 | let sent = self | |
| 399 | .db | |
| 400 | .prepare("SELECT MAX(level) AS level FROM budget_alerts WHERE account = ? AND month = ?") | |
| 401 | .bind(&[id.as_str().into(), month.as_str().into()])? | |
| 402 | .first::<Sent>(None) | |
| 403 | .await? | |
| 404 | .and_then(|s| s.level) | |
| 405 | .unwrap_or(0); | |
| 406 | let Some(level) = alert_to_send(budget.level, u32::try_from(sent).unwrap_or(0)) else { continue }; | |
| 407 | let name = &account.name; | |
| 408 | let mut lines = vec![format!( | |
| 409 | "{name}'s work has cost g1t {} this month, {level}% of its {} monthly budget ({}).", | |
| 410 | cents(budget.used_micros), | |
| 411 | cents(budget.ceiling_micros), | |
| 412 | if budget.default_ceiling { "COMPED_MONTHLY_CEILING_MICROS" } else { "its own limit, in its terms" } | |
| 413 | )]; | |
| 414 | lines.push(if level >= 100 { | |
| 415 | format!("New agent runs, checks and builds on {name} are refused until the budget is raised or the month turns. Runs already going finish. To raise it: sudo, Accounts, {name}, Terms, Limit.") | |
| 416 | } else { | |
| 417 | format!("At 100%, new work on {name} is refused until staff raise the budget. To raise it now: sudo, Accounts, {name}, Terms, Limit.") | |
| 418 | }); | |
| 419 | let subject = format!("g1t: {name} has used {level}% of its monthly budget"); | |
| 420 | match crate::margin::email_staff(&self.env, &self.caps.alert_to, &subject, &lines).await { | |
| 421 | Ok(()) => { | |
| 422 | self.db | |
| 423 | .prepare("INSERT OR IGNORE INTO budget_alerts (account, month, level, sent_at) VALUES (?, ?, ?, ?)") | |
| 424 | .bind(&[id.as_str().into(), month.as_str().into(), level.into(), rfc3339(now_ms()).into()])? | |
| 425 | .run() | |
| 426 | .await?; | |
| 427 | } | |
| 428 | Err(error) => worker::console_error!("could not email {name}'s budget alert: {error}"), | |
| 429 | } | |
| 430 | } | |
| 431 | // A trip whose email did not go out when it happened. | |
| 432 | let day = today(); | |
| 433 | if self.breaker_row(&day).await?.is_some_and(|row| row.tripped_at.is_some() && row.told_at.is_none()) { | |
| 434 | let total = self.spent_on(&day).await?; | |
| 435 | self.tell_breaker(&day, total).await?; | |
| 436 | } | |
| 437 | Ok(()) | |
| 438 | } | |
| 439 | ||
| 440 | /// `admin_spend_caps`: g1t's own spend against its caps. | |
| 441 | pub(crate) async fn spend_caps(&self) -> Result<SpendCaps> { | |
| 442 | let day = today(); | |
| 443 | let month = day[..7].to_owned(); | |
| 444 | let month_start = format!("{month}-01"); | |
| 445 | let today_micros = self.spent_on(&day).await?; | |
| 446 | let row = self.breaker_row(&day).await?; | |
| 447 | let lifted = row.as_ref().is_some_and(|r| r.lifted_at.is_some()); | |
| 448 | #[derive(Deserialize)] | |
| 449 | struct Bucket { | |
| 450 | bucket: String, | |
| 451 | micros: Option<i64>, | |
| 452 | } | |
| 453 | let rows = self | |
| 454 | .db | |
| 455 | .prepare("SELECT bucket, SUM(micros) AS micros FROM g1t_spend WHERE day >= ? GROUP BY bucket") | |
| 456 | .bind(&[month_start.as_str().into()])? | |
| 457 | .all() | |
| 458 | .await? | |
| 459 | .results::<Bucket>()?; | |
| 460 | let month_buckets = ["comped", "trial", "oss", "given", "unpaid"] | |
| 461 | .iter() | |
| 462 | .map(|bucket| SpendBucket { | |
| 463 | bucket: (*bucket).to_owned(), | |
| 464 | title: bucket_title(bucket).to_owned(), | |
| 465 | micros: rows.iter().find(|r| r.bucket == *bucket).and_then(|r| r.micros).unwrap_or(0), | |
| 466 | }) | |
| 467 | .collect(); | |
| 468 | #[derive(Deserialize)] | |
| 469 | struct Id { | |
| 470 | id: String, | |
| 471 | } | |
| 472 | let ids = self | |
| 473 | .db | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 474 | .prepare(format!("SELECT id FROM billing_accounts WHERE {} ORDER BY id", crate::sales::FULL_DISCOUNT_SQL)) |
| Fixes from the page sweep | 475 | .all() |
| 476 | .await? | |
| 477 | .results::<Id>()?; | |
| 478 | let mut comped = vec![]; | |
| 479 | for Id { id } in ids { | |
| 480 | if let Some(account) = self.find_account(&id).await? { | |
| 481 | comped.push(self.comped_budget(&account).await?); | |
| 482 | } | |
| 483 | } | |
| 484 | // Free workspaces' share of the reconciled costs that are not on | |
| 485 | // the ledger (models, sandboxes and builds are, above). | |
| 486 | let free_tier_micros = self | |
| 487 | .db | |
| 488 | .prepare(format!( | |
| 489 | "SELECT SUM(cost_micros) AS micros FROM workspace_costs | |
| 490 | WHERE day >= ?1 AND bucket NOT IN ('models', 'sandboxes', 'deployments') | |
| 491 | AND workspace NOT IN ({internal}) | |
| 492 | AND workspace NOT IN (SELECT workspace FROM workspace_costs WHERE day >= ?1 GROUP BY workspace HAVING SUM(revenue_micros) > 0)", | |
| 493 | internal = crate::sales::INTERNAL_SQL | |
| 494 | )) | |
| 495 | .bind(&[month_start.as_str().into()])? | |
| 496 | .first::<Sum>(None) | |
| 497 | .await? | |
| 498 | .and_then(|s| s.micros) | |
| 499 | .unwrap_or(0); | |
| 500 | let revenue_micros = self | |
| 501 | .db | |
| 502 | .prepare("SELECT SUM(cash_micros) AS micros FROM margin_days WHERE day >= ?") | |
| 503 | .bind(&[month_start.as_str().into()])? | |
| 504 | .first::<Sum>(None) | |
| 505 | .await? | |
| 506 | .and_then(|s| s.micros) | |
| 507 | .unwrap_or(0); | |
| Merge costs and margin review: gateway query, own spend, discount meters, superseded rises | 508 | // What a testing reset wiped from the ledger is still here. |
| 509 | #[derive(Deserialize)] | |
| 510 | struct Reset { | |
| 511 | account: String, | |
| 512 | micros: Option<i64>, | |
| 513 | } | |
| 514 | let reset = self | |
| 515 | .db | |
| 516 | .prepare(RESET_SPEND_SQL) | |
| 517 | .bind(&[month_start.as_str().into()])? | |
| 518 | .all() | |
| 519 | .await? | |
| 520 | .results::<Reset>()?; | |
| 521 | let reset_micros = reset.iter().filter_map(|r| r.micros).sum(); | |
| 522 | let reset_workspaces = reset.into_iter().map(|r| r.account.strip_prefix("ws_").unwrap_or(&r.account).to_owned()).collect(); | |
| Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing | 523 | let fixed = self.fixed_monthly(self.caps.fixed_monthly).await?; |
| 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) | 524 | // This month's days so far, each its billing cycle's share: the |
| 525 | // same accrual as the statement's range (`cycle::accrued`). | |
| 526 | let fixed_month_micros = crate::cycle::accrued(fixed.monthly_micros, &month_start, &day, self.cycle_anchor().await?); | |
| Fixes from the page sweep | 527 | Ok(SpendCaps { |
| Merge costs and margin review: gateway query, own spend, discount meters, superseded rises | 528 | reset_micros, |
| 529 | reset_workspaces, | |
| Fixes from the page sweep | 530 | day, |
| 531 | month, | |
| 532 | today_micros, | |
| 533 | daily_cap_micros: self.caps.daily, | |
| 534 | tripped: breaker_open(today_micros, self.caps.daily, lifted), | |
| 535 | tripped_at: row.as_ref().and_then(|r| r.tripped_at.clone()), | |
| 536 | lifted_by: row.as_ref().and_then(|r| r.lifted_by.clone()), | |
| 537 | lifted_at: row.as_ref().and_then(|r| r.lifted_at.clone()), | |
| 538 | lift_note: row.as_ref().and_then(|r| r.lift_note.clone()), | |
| 539 | month_buckets, | |
| 540 | comped, | |
| 541 | free_tier_micros, | |
| Costs: Cloudflare's subscriptions read from Cloudflare each day, the estimate only until then; sudo's costs split into Costs & margin and Bill & pricing | 542 | fixed_monthly_micros: fixed.monthly_micros, |
| 543 | fixed_source: fixed.source.into(), | |
| 544 | fixed_read_at: fixed.read_at, | |
| 545 | fixed_items: fixed.items, | |
| 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) | 546 | fixed_month_micros, |
| Fixes from the page sweep | 547 | revenue_micros, |
| 548 | }) | |
| 549 | } | |
| 550 | ||
| 551 | /// `admin_lift_breaker`: hosted-model runs start again for the rest of | |
| 552 | /// today (UTC). | |
| 553 | pub(crate) async fn admin_lift_breaker(&self, a: AdminLiftBreakerArgs) -> Result<Outcome<SpendCaps>> { | |
| 554 | let (by, note) = (a.by.trim(), a.note.trim()); | |
| 555 | if by.is_empty() || note.len() < 5 { | |
| 556 | return Ok(Outcome::fail(FailureCode::Invalid, "Say who is lifting it, and why, in the note.")); | |
| 557 | } | |
| 558 | let day = today(); | |
| 559 | let now = rfc3339(now_ms()); | |
| 560 | let note: String = note.chars().take(500).collect(); | |
| 561 | self.db | |
| 562 | .prepare( | |
| 563 | "INSERT INTO spend_breaker (day, lifted_by, lifted_at, lift_note) VALUES (?1, ?2, ?3, ?4) | |
| 564 | ON CONFLICT (day) DO UPDATE SET lifted_by = ?2, lifted_at = ?3, lift_note = ?4", | |
| 565 | ) | |
| 566 | .bind(&[day.as_str().into(), by.into(), now.as_str().into(), note.as_str().into()])? | |
| 567 | .run() | |
| 568 | .await?; | |
| 569 | let spent = self.spent_on(&day).await?; | |
| 570 | self.audit("costs", "breaker_lifted", &format!("{day}: lifted at {} of {}: {note}", cents(spent), cents(self.caps.daily)), by) | |
| 571 | .await?; | |
| 572 | Ok(Outcome::Ok(self.spend_caps().await?)) | |
| 573 | } | |
| 574 | } | |
| 575 | ||
| Merge costs and margin review: gateway query, own spend, discount meters, superseded rises | 576 | /// g1t's own spend since `?1` on accounts a testing reset wiped later than |
| 577 | /// the day it was spent (`admin_actions`, action `reset`), by account. | |
| 578 | pub(crate) const RESET_SPEND_SQL: &str = "SELECT s.account, SUM(s.micros) AS micros FROM g1t_spend s | |
| 579 | WHERE s.day >= ?1 AND EXISTS (SELECT 1 FROM admin_actions a WHERE a.action = 'reset' AND a.account = s.account AND substr(a.created_at, 1, 10) >= s.day) | |
| 580 | GROUP BY s.account HAVING SUM(s.micros) > 0 ORDER BY s.account"; | |
| 581 | ||
| Fixes from the page sweep | 582 | /// How sudo names a bucket of g1t's own spend. |
| 583 | pub(crate) fn bucket_title(bucket: &str) -> &'static str { | |
| 584 | match bucket { | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 585 | "comped" => "100% discount (g1t's own)", |
| Fixes from the page sweep | 586 | "trial" => "Trial pool", |
| 587 | "oss" => "Open-source pool", | |
| 588 | "given" => "Free overruns g1t covered", | |
| 589 | "unpaid" => "Charged without real money", | |
| 590 | _ => "Other", | |
| 591 | } | |
| 592 | } | |
| 593 | ||
| 594 | #[cfg(test)] | |
| 595 | mod tests { | |
| 596 | use super::*; | |
| 597 | ||
| 598 | fn drawn(credit: i64, trial: i64, oss: i64, given: i64) -> Drawn { | |
| 599 | Drawn { credit, trial, oss, given } | |
| 600 | } | |
| 601 | ||
| 602 | #[test] | |
| 603 | fn comped_work_is_all_g1ts_at_cost() { | |
| 604 | let s = share(1_000_000, 0, &Drawn::default(), true, true); | |
| 605 | assert_eq!(s, Share { comped: 1_000_000, ..Share::default() }); | |
| 606 | // Nothing that cost nothing is counted. | |
| 607 | assert_eq!(share(0, 0, &Drawn::default(), true, true).total(), 0); | |
| 608 | assert_eq!(share(-5, 0, &Drawn::default(), false, false).total(), 0); | |
| 609 | } | |
| 610 | ||
| 611 | #[test] | |
| 612 | fn pools_pay_their_share_of_the_cost_not_the_price() { | |
| 613 | // $1 of cost charged at $1.20, all from the trial: $1 is g1t's. | |
| 614 | assert_eq!(share(1_000_000, 0, &drawn(0, 1_200_000, 0, 0), false, true), Share { trial: 1_000_000, ..Share::default() }); | |
| 615 | // Half the open-source pool, half charged on a live card: half is g1t's. | |
| 616 | assert_eq!(share(1_000_000, 600_000, &drawn(0, 0, 600_000, 0), false, true), Share { oss: 500_000, ..Share::default() }); | |
| 617 | // A free workspace's overrun past its trial. | |
| 618 | let s = share(1_000_000, 0, &drawn(0, 300_000, 0, 900_000), false, true); | |
| 619 | assert_eq!((s.trial, s.given), (250_000, 750_000)); | |
| 620 | } | |
| 621 | ||
| 622 | #[test] | |
| 623 | fn revenue_is_only_revenue_with_real_money() { | |
| 624 | // Plan credit and an on-demand charge, live: none of it is g1t's. | |
| 625 | assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, true).total(), 0); | |
| 626 | // The same in test mode: all of it. | |
| 627 | assert_eq!(share(1_000_000, 600_000, &drawn(600_000, 0, 0, 0), false, false), Share { unpaid: 1_000_000, ..Share::default() }); | |
| 628 | // Free while building: charged nothing, all g1t's. | |
| 629 | assert_eq!(share(1_000_000, 0, &Drawn::default(), false, true), Share { unpaid: 1_000_000, ..Share::default() }); | |
| 630 | } | |
| 631 | ||
| 632 | #[test] | |
| 633 | fn a_comped_account_gets_the_default_budget_unless_its_terms_set_one() { | |
| 634 | assert_eq!(comped_ceiling(None, 150_000_000), (150_000_000, true)); | |
| 635 | assert_eq!(comped_ceiling(Some(400_000_000), 150_000_000), (400_000_000, false)); | |
| 636 | // Zero: no budget. | |
| 637 | assert_eq!(comped_ceiling(None, 0), (0, true)); | |
| 638 | assert!(!used_up(1_000_000_000, 0)); | |
| 639 | } | |
| 640 | ||
| 641 | #[test] | |
| 642 | fn a_comped_budget_refuses_new_work_at_one_hundred_percent() { | |
| 643 | let ceiling = 150_000_000; | |
| 644 | assert!(!used_up(149_999_999, ceiling)); | |
| 645 | assert!(used_up(150_000_000, ceiling)); | |
| 646 | assert!(used_up(151_000_000, ceiling)); | |
| 647 | let message = comped_refusal("flagon-io", 150_000_000, ceiling); | |
| 648 | assert!(message.contains("used up") && message.contains("$150.00 of $150.00") && message.contains("sudo"), "{message}"); | |
| 649 | } | |
| 650 | ||
| 651 | #[test] | |
| 652 | fn budget_alerts_go_once_per_level_each_month() { | |
| 653 | let ceiling = 150_000_000; | |
| 654 | let mut sent = 0; | |
| 655 | let mut emailed = vec![]; | |
| 656 | // Spend climbs through the month, checked every 15 minutes. | |
| 657 | for used in [10_000_000, 74_000_000, 75_000_000, 80_000_000, 112_500_000, 120_000_000, 135_000_000, 140_000_000, 150_000_000, 170_000_000] { | |
| 658 | if let Some(level) = alert_to_send(alert_level(used, ceiling), sent) { | |
| 659 | emailed.push(level); | |
| 660 | sent = level; | |
| 661 | } | |
| 662 | } | |
| 663 | assert_eq!(emailed, vec![50, 75, 90, 100]); | |
| 664 | // A jump straight past several levels sends only the highest. | |
| 665 | assert_eq!(alert_to_send(alert_level(140_000_000, ceiling), 0), Some(90)); | |
| 666 | // A new month starts from nothing sent. | |
| 667 | assert_eq!(alert_to_send(alert_level(80_000_000, ceiling), 0), Some(50)); | |
| 668 | } | |
| 669 | ||
| 670 | #[test] | |
| 671 | fn the_breaker_trips_at_the_cap_and_staff_can_lift_it_for_the_day() { | |
| 672 | let cap = 75_000_000; | |
| 673 | assert!(!breaker_open(74_999_999, cap, false)); | |
| 674 | assert!(breaker_open(75_000_000, cap, false)); | |
| 675 | // Lifted: open no more today. | |
| 676 | assert!(!breaker_open(90_000_000, cap, true)); | |
| 677 | // Off. | |
| 678 | assert!(!breaker_open(1_000_000_000, 0, false)); | |
| 679 | // Tomorrow's total starts at zero: the breaker resets by itself. | |
| 680 | assert!(!breaker_open(0, cap, false)); | |
| 681 | let message = breaker_refusal(80_000_000, cap); | |
| 682 | assert!(message.contains("$80.00 of its $75.00") && message.contains("00:00 UTC"), "{message}"); | |
| 683 | } | |
| 684 | ||
| 685 | #[test] | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 686 | fn the_breaker_is_about_all_compute_g1t_pays_for() { |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 687 | assert!(breaker_applies(ComputeKind::Check)); |
| 688 | assert!(breaker_applies(ComputeKind::Workflow)); | |
| 689 | assert!(breaker_applies(ComputeKind::Deploy)); | |
| Fixes from the page sweep | 690 | } |
| 691 | ||
| 692 | #[test] | |
| 693 | fn workspaces_paying_with_real_money_are_never_paused_by_the_breaker() { | |
| 694 | assert!(covered_by_revenue(PlanKind::Paid, false, false, true)); | |
| 695 | assert!(covered_by_revenue(PlanKind::Enterprise, false, false, true)); | |
| 696 | // Test-mode payments are not money. | |
| 697 | assert!(!covered_by_revenue(PlanKind::Paid, false, false, false)); | |
| 698 | // The plan given by staff, comped, free: g1t pays. | |
| 699 | assert!(!covered_by_revenue(PlanKind::Paid, false, true, true)); | |
| 700 | assert!(!covered_by_revenue(PlanKind::Internal, true, false, true)); | |
| 701 | assert!(!covered_by_revenue(PlanKind::Free, false, false, true)); | |
| 702 | } | |
| 703 | ||
| 704 | #[test] | |
| Merge costs and margin review: gateway query, own spend, discount meters, superseded rises | 705 | fn spend_a_testing_reset_wiped_is_found_by_the_reset_after_it() { |
| 706 | // syntaqx's $7.41 of 2026-10-02 to 10-05, reset on 10-07: still | |
| 707 | // g1t's spend, gone from its ledger. | |
| 708 | assert_eq!(crate::rename::parameters(RESET_SPEND_SQL), 1); | |
| 709 | assert!(RESET_SPEND_SQL.contains("a.action = 'reset'") && RESET_SPEND_SQL.contains("substr(a.created_at, 1, 10) >= s.day")); | |
| 710 | } | |
| 711 | ||
| 712 | #[test] | |
| Fixes from the page sweep | 713 | fn amounts_read_in_cents() { |
| 714 | assert_eq!(cents(150_000_000), "$150.00"); | |
| 715 | assert_eq!(cents(1_234_567), "$1.23"); | |
| 716 | assert_eq!(cents(5_000), "$0.01"); | |
| 717 | } | |
| 718 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.