Usage, Billing settings and prepaid AI credit; fixes from the UX audit
Usage (/:owner/-/usage, and per project /:owner/:repo/usage) - One row of filters (period or range, products, projects, group by), CSV and API links; included usage, AI credit and credit from g1t; usage at price, discount, included, credits applied and charged for the range. - A stacked consumption chart by product (daily, weekly, monthly, cumulative) with a table view, and a breakdown by product family with sparklines, allowance rings and a per-project split. Streams in behind skeletons of the same height. - One figure everywhere for what usage came to: mission control, the agent fleet, the sidebar, Usage and Billing read "usage at price" from the same ledger calculation; "other" is "Not tied to a project". Billing settings - Plan card with the upcoming invoice; AI credit (buy $10/$25/$50/$100 or custom through Stripe Checkout, auto-reload with a monthly maximum); spend limit and budget alerts (50/75/90/100%, pause at 100%, webhook); the default card from Stripe with Manage in Stripe; add-ons; invoice details saved on the Stripe customer; invoices with PDFs. - Billing RPCs usage_report, ai_credit, buy_ai_credit, confirm_ai_credit, set_ai_reload, set_budget, billing_details, set_billing_details; the 15-minute cron runs auto-reload. REST under /workspaces/:workspace/..., scopes billing:read and billing:write, an MCP billing tool; agents never write billing. Pricing (billing 0040, dated price versions with public change records) - Agent models at the provider's price (markup 20% -> 0, a cut, at once); the g1t agent rate, $0.25 per million tokens, from 2026-10-22 after notice; AI Gateway markup 0, free during beta; a card processing fee line that sudo can switch off; purchased AI credit pays for models first, expires after a year and is credited exactly once; $5 promotional AI credit once when a plan first becomes active; a paying workspace with no AI credit and no included usage left cannot start runs on g1t's models (auto-reload is tried first; 100% discounts and enterprises exempt). - AI credit never pays for sandbox or storage on invoices or at month end; models-only credit no longer pays off other debt. Stripe - Every request pins Stripe-Version 2025-02-24.acacia; webhook invoices are read in both layouts; payment pages share one insert (tested against the migrations) and idempotency keys; every Stripe refusal shows as a sentence on the page instead of a 500. Starting the plan uses the customer's default card. The test-card hint shows only to g1t staff. UX audit - Errors render inside the signed-in frame (a failing sidebar call falls back to the session); a stale build after a deploy reloads once. - The workspace switcher follows a project's owner; Security reads live visibility (repos created were all recorded private). - Previews stop when their commit is gone or after 3 identical failures, say why, and repeat failures group into one row; build logs wrap. - "Needs you" means one thing; the inbox card is "From your inbox"; activity shows usernames; waits read "17 h"; a job waiting for a runner says so. - Exact ahead/behind per branch (cached by head pair); "Nothing to merge". - Phone: only the last breadcrumb with a back arrow; tab strips scroll sideways (TabStrip); the menu drawer is a Sheet with a focus trap. - One SOON pill everywhere; Milestones is a real tab. - Inline markdown in Agent fleet and Context rows; memory facts deduped. - Mission control's skeleton matches its layout; diffs arrive highlighted. - Sidebar data cached 30 s per viewer; workspace Security queries run in parallel. - Ctrl K on non-Mac and no hint on touch; Skip to content; palette focus returns; page titles; clone URLs don't break mid-word; check commands are no longer shell-escaped; docs tables don't break inline code.
| 1 | + | //! Billing: a workspace's usage, budget, AI credit and invoices. The | |
| 2 | + | //! billing service keeps them and answers in camelCase; this is their | |
| 3 | + | //! public shape, in snake_case, with money as whole millionths of a dollar | |
| 4 | + | //! (`_micros`) or, for invoices, cents (`_cents`). | |
| 5 | + | //! | |
| 6 | + | //! Reading is for the workspace's members, a workspace's own token | |
| 7 | + | //! included. Changing the budget and buying AI credit are for its owners, | |
| 8 | + | //! as people: signed in or with a personal access token. A workspace's | |
| 9 | + | //! token and g1t's agents never change billing, whatever their scopes say. | |
| 10 | + | ||
| 11 | + | use std::collections::BTreeMap; | |
| 12 | + | ||
| 13 | + | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 14 | + | use serde_json::{Map, Value, json}; | |
| 15 | + | use worker::Result; | |
| 16 | + | ||
| 17 | + | use crate::operations::{Op, Services}; | |
| 18 | + | ||
| 19 | + | /// The product families usage is grouped into, in order. | |
| 20 | + | pub(crate) const PRODUCTS: [&str; 8] = | |
| 21 | + | ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]; | |
| 22 | + | ||
| 23 | + | /// Where a budget's alerts can be, in percent of its limit. | |
| 24 | + | pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100]; | |
| 25 | + | ||
| 26 | + | /// How usage can be added up over its range. | |
| 27 | + | pub(crate) const GROUPS: [&str; 3] = ["product", "project", "day"]; | |
| 28 | + | ||
| 29 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 30 | + | Ok(Outcome::fail(code, message)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// `camelCase` as `snake_case`. | |
| 34 | + | fn snake_key(key: &str) -> String { | |
| 35 | + | let mut out = String::with_capacity(key.len() + 4); | |
| 36 | + | for c in key.chars() { | |
| 37 | + | if c.is_ascii_uppercase() { | |
| 38 | + | if !out.is_empty() { | |
| 39 | + | out.push('_'); | |
| 40 | + | } | |
| 41 | + | out.push(c.to_ascii_lowercase()); | |
| 42 | + | } else { | |
| 43 | + | out.push(c); | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | out | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// A service's answer with every object key in `snake_case`, all the way | |
| 50 | + | /// down. Billing's answers have no keys that are data, so all of them are | |
| 51 | + | /// names. | |
| 52 | + | pub(crate) fn snake(value: &Value) -> Value { | |
| 53 | + | match value { | |
| 54 | + | Value::Object(fields) => { | |
| 55 | + | Value::Object(fields.iter().map(|(key, value)| (snake_key(key), snake(value))).collect()) | |
| 56 | + | } | |
| 57 | + | Value::Array(items) => Value::Array(items.iter().map(snake).collect()), | |
| 58 | + | other => other.clone(), | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Strings given as an array, or as one string separated by commas (a | |
| 63 | + | /// query string's way). | |
| 64 | + | fn list(input: &Value, key: &str) -> Vec<String> { | |
| 65 | + | let items: Vec<String> = match &input[key] { | |
| 66 | + | Value::Array(items) => items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect(), | |
| 67 | + | Value::String(text) => text.split(',').map(str::to_owned).collect(), | |
| 68 | + | _ => Vec::new(), | |
| 69 | + | }; | |
| 70 | + | items.into_iter().map(|item| item.trim().to_owned()).filter(|item| !item.is_empty()).collect() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | fn workspace(input: &Value) -> Option<String> { | |
| 74 | + | input["workspace"].as_str().map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// `YYYY-MM-DD`. | |
| 78 | + | fn is_day(text: &str) -> bool { | |
| 79 | + | let bytes = text.as_bytes(); | |
| 80 | + | bytes.len() == 10 | |
| 81 | + | && bytes.iter().enumerate().all(|(at, byte)| if at == 4 || at == 7 { *byte == b'-' } else { byte.is_ascii_digit() }) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// The UTC day `days` after 1970-01-01, as `(year, month, day)`. | |
| 85 | + | fn civil(days: i64) -> (i64, u32, u32) { | |
| 86 | + | let z = days + 719_468; | |
| 87 | + | let era = z.div_euclid(146_097); | |
| 88 | + | let doe = z.rem_euclid(146_097); | |
| 89 | + | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 90 | + | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 91 | + | let mp = (5 * doy + 2) / 153; | |
| 92 | + | let day = (doy - (153 * mp + 2) / 5 + 1) as u32; | |
| 93 | + | let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; | |
| 94 | + | let year = yoe + era * 400 + i64::from(month <= 2); | |
| 95 | + | (year, month, day) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// The first day of the current UTC month, and today, at `now_ms`. | |
| 99 | + | pub(crate) fn this_month(now_ms: f64) -> (String, String) { | |
| 100 | + | let (year, month, day) = civil((now_ms / 86_400_000.0).floor() as i64); | |
| 101 | + | (format!("{year:04}-{month:02}-01"), format!("{year:04}-{month:02}-{day:02}")) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// The person who may change a workspace's billing: a person, never a | |
| 105 | + | /// workspace's own token or one of g1t's agents. `agent_scoped` is whether | |
| 106 | + | /// the request came with an agent's token. | |
| 107 | + | pub(crate) fn person(viewer: &Viewer, agent_scoped: bool) -> std::result::Result<&User, (FailureCode, &'static str)> { | |
| 108 | + | match viewer { | |
| 109 | + | None => Err((FailureCode::Unauthenticated, "This needs a g1t access token.")), | |
| 110 | + | Some(user) if agent_scoped || user.kind == PrincipalKind::Agent => Err(( | |
| 111 | + | FailureCode::Forbidden, | |
| 112 | + | "g1t's agents never change billing: a workspace's budget and AI credit are for its owners.", | |
| 113 | + | )), | |
| 114 | + | Some(user) if user.kind != PrincipalKind::User => Err(( | |
| 115 | + | FailureCode::Forbidden, | |
| 116 | + | "Changing billing needs a person: sign in, or use a personal access token. A workspace's own token can read billing, not change it.", | |
| 117 | + | )), | |
| 118 | + | Some(user) => Ok(user), | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// A usage report (camelCase, as billing answers) in its public shape, | |
| 123 | + | /// with `groups` when `group_by` asks for them. | |
| 124 | + | pub(crate) fn usage_json(report: &Value, group_by: Option<&str>) -> Value { | |
| 125 | + | let mut out = snake(report); | |
| 126 | + | if let (Some(by), Some(fields)) = (group_by, out.as_object_mut()) { | |
| 127 | + | fields.insert("group_by".to_owned(), json!(by)); | |
| 128 | + | fields.insert("groups".to_owned(), groups(report, by)); | |
| 129 | + | } | |
| 130 | + | out | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | fn micros(value: &Value) -> i64 { | |
| 134 | + | value.as_i64().or_else(|| value.as_f64().map(|n| n as i64)).unwrap_or(0) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// The report's range added up by product (every family, in order), by | |
| 138 | + | /// project (most first; `key` null for usage that is no one project's) or | |
| 139 | + | /// by day (oldest first). | |
| 140 | + | fn groups(report: &Value, by: &str) -> Value { | |
| 141 | + | let products = report["products"].as_array().cloned().unwrap_or_default(); | |
| 142 | + | match by { | |
| 143 | + | "product" => Value::Array( | |
| 144 | + | products | |
| 145 | + | .iter() | |
| 146 | + | .map(|product| json!({ "key": product["key"], "label": product["label"], "micros": micros(&product["micros"]) })) | |
| 147 | + | .collect(), | |
| 148 | + | ), | |
| 149 | + | "project" => { | |
| 150 | + | let mut sums: BTreeMap<String, i64> = BTreeMap::new(); | |
| 151 | + | for product in &products { | |
| 152 | + | for meter in product["meters"].as_array().into_iter().flatten() { | |
| 153 | + | for part in meter["byProject"].as_array().into_iter().flatten() { | |
| 154 | + | *sums.entry(part["project"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&part["micros"]); | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | let mut sums: Vec<(String, i64)> = sums.into_iter().collect(); | |
| 159 | + | sums.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); | |
| 160 | + | Value::Array( | |
| 161 | + | sums.into_iter() | |
| 162 | + | .map(|(project, micros)| { | |
| 163 | + | let key = if project.is_empty() { Value::Null } else { Value::String(project) }; | |
| 164 | + | json!({ "key": key, "micros": micros }) | |
| 165 | + | }) | |
| 166 | + | .collect(), | |
| 167 | + | ) | |
| 168 | + | } | |
| 169 | + | _ => { | |
| 170 | + | let mut sums: BTreeMap<String, i64> = BTreeMap::new(); | |
| 171 | + | for day in report["days"].as_array().into_iter().flatten() { | |
| 172 | + | *sums.entry(day["day"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&day["micros"]); | |
| 173 | + | } | |
| 174 | + | Value::Array(sums.into_iter().map(|(day, micros)| json!({ "key": day, "micros": micros })).collect()) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// A workspace's limit (camelCase, as billing answers) as its budget. | |
| 180 | + | pub(crate) fn budget_json(limit: &Value) -> Value { | |
| 181 | + | json!({ | |
| 182 | + | "workspace": limit["workspace"], | |
| 183 | + | "amount_micros": limit["spendLimitMicros"], | |
| 184 | + | "automatic": limit["defaultSpendLimit"].as_bool().unwrap_or(false), | |
| 185 | + | "spent_micros": micros(&limit["spentMicros"]), | |
| 186 | + | "max_amount_micros": limit["availableMicros"], | |
| 187 | + | "alerts": limit["alertLevels"].as_array().cloned().unwrap_or_default(), | |
| 188 | + | "pause_at_limit": limit["pauseAtLimit"].as_bool().unwrap_or(true), | |
| 189 | + | "webhook": limit["budgetWebhook"], | |
| 190 | + | "state": limit["state"], | |
| 191 | + | "message": limit["message"], | |
| 192 | + | }) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// What set_budget asks billing for: the fields given, and the rest as | |
| 196 | + | /// they are in `current` (the workspace's limit, camelCase). | |
| 197 | + | #[derive(Debug, PartialEq)] | |
| 198 | + | pub(crate) struct BudgetChange { | |
| 199 | + | /// No amount was given: billing leaves the limit as it is, whatever | |
| 200 | + | /// it is by the time it is asked. | |
| 201 | + | pub keep_limit: bool, | |
| 202 | + | pub amount_micros: Option<i64>, | |
| 203 | + | pub alerts: Vec<u32>, | |
| 204 | + | pub pause_at_limit: bool, | |
| 205 | + | pub webhook: Option<String>, | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | pub(crate) fn budget_change(input: &Value, current: &Value) -> std::result::Result<BudgetChange, String> { | |
| 209 | + | let amount_micros = match input.get("amount_micros") { | |
| 210 | + | None if current["defaultSpendLimit"].as_bool() == Some(true) => None, | |
| 211 | + | None => current["spendLimitMicros"].as_i64(), | |
| 212 | + | Some(Value::Null) => None, | |
| 213 | + | Some(value) => { | |
| 214 | + | let amount = value.as_i64().or_else(|| value.as_str().and_then(|digits| digits.trim().parse().ok())); | |
| 215 | + | match amount { | |
| 216 | + | Some(amount) if amount >= 0 => Some(amount), | |
| 217 | + | _ => return Err("amount_micros is a whole number of millionths of a dollar, or null for the automatic limit.".to_owned()), | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | }; | |
| 221 | + | let alerts = match input.get("alerts") { | |
| 222 | + | None | Some(Value::Null) => current["alertLevels"] | |
| 223 | + | .as_array() | |
| 224 | + | .map(|levels| levels.iter().filter_map(|level| level.as_u64()).filter_map(|level| u32::try_from(level).ok()).collect()) | |
| 225 | + | .unwrap_or_default(), | |
| 226 | + | Some(Value::Array(levels)) => { | |
| 227 | + | let mut chosen = Vec::new(); | |
| 228 | + | for level in levels { | |
| 229 | + | let level = level.as_u64().or_else(|| level.as_str().and_then(|digits| digits.trim().parse().ok())); | |
| 230 | + | match level.and_then(|level| u32::try_from(level).ok()).filter(|level| ALERT_LEVELS.contains(level)) { | |
| 231 | + | Some(level) if !chosen.contains(&level) => chosen.push(level), | |
| 232 | + | Some(_) => {} | |
| 233 | + | None => return Err("alerts are some of 50, 75, 90 and 100.".to_owned()), | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | chosen.sort_unstable(); | |
| 237 | + | chosen | |
| 238 | + | } | |
| 239 | + | Some(_) => return Err("alerts is a list: some of 50, 75, 90 and 100.".to_owned()), | |
| 240 | + | }; | |
| 241 | + | let pause_at_limit = match input.get("pause_at_limit") { | |
| 242 | + | None | Some(Value::Null) => current["pauseAtLimit"].as_bool().unwrap_or(true), | |
| 243 | + | Some(Value::Bool(pause)) => *pause, | |
| 244 | + | Some(Value::String(word)) if word == "true" || word == "false" => word == "true", | |
| 245 | + | Some(_) => return Err("pause_at_limit is true or false.".to_owned()), | |
| 246 | + | }; | |
| 247 | + | let webhook = match input.get("webhook") { | |
| 248 | + | None => current["budgetWebhook"].as_str().map(str::to_owned), | |
| 249 | + | Some(Value::Null) => None, | |
| 250 | + | Some(Value::String(url)) if url.trim().is_empty() => None, | |
| 251 | + | Some(Value::String(url)) if url.trim().starts_with("https://") => Some(url.trim().to_owned()), | |
| 252 | + | Some(_) => return Err("webhook is an https:// address, or null for none.".to_owned()), | |
| 253 | + | }; | |
| 254 | + | Ok(BudgetChange { keep_limit: input.get("amount_micros").is_none(), amount_micros, alerts, pause_at_limit, webhook }) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// Billing details without the invoices, which list_invoices gives. | |
| 258 | + | pub(crate) fn details_json(details: &Value) -> Value { | |
| 259 | + | let mut out = snake(details); | |
| 260 | + | if let Some(fields) = out.as_object_mut() { | |
| 261 | + | fields.remove("invoices"); | |
| 262 | + | fields.remove("upcoming"); | |
| 263 | + | fields.remove("unavailable"); | |
| 264 | + | } | |
| 265 | + | out | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Every invoice billed to the workspace, g1t's itemised usage invoices, | |
| 269 | + | /// and what the next one comes to so far. | |
| 270 | + | pub(crate) fn invoices_json(details: &Value, usage: &[Value]) -> Value { | |
| 271 | + | let usage: Vec<Value> = usage | |
| 272 | + | .iter() | |
| 273 | + | .map(|invoice| { | |
| 274 | + | let mut fields = Map::new(); | |
| 275 | + | fields.insert("id".to_owned(), invoice["invoiceId"].clone()); | |
| 276 | + | if let Value::Object(rest) = snake(invoice) { | |
| 277 | + | fields.extend(rest.into_iter().filter(|(key, _)| key != "invoice_id")); | |
| 278 | + | } | |
| 279 | + | Value::Object(fields) | |
| 280 | + | }) | |
| 281 | + | .collect(); | |
| 282 | + | json!({ | |
| 283 | + | "invoices": snake(&details["invoices"]).as_array().cloned().unwrap_or_default(), | |
| 284 | + | "usage_invoices": usage, | |
| 285 | + | "upcoming": snake(&details["upcoming"]), | |
| 286 | + | "unavailable": details["unavailable"], | |
| 287 | + | }) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /// Runs one of the billing operations. | |
| 291 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 292 | + | if viewer.is_none() { | |
| 293 | + | return failed(FailureCode::Unauthenticated, "This needs a g1t access token."); | |
| 294 | + | } | |
| 295 | + | let Some(workspace) = workspace(input) else { | |
| 296 | + | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 297 | + | }; | |
| 298 | + | let billing = &services.billing; | |
| 299 | + | let shaped = |outcome: Outcome<Value>, shape: &dyn Fn(&Value) -> Value| -> Result<Outcome<Value>> { | |
| 300 | + | Ok(match outcome { | |
| 301 | + | Outcome::Ok(value) => Outcome::Ok(shape(&value)), | |
| 302 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 303 | + | }) | |
| 304 | + | }; | |
| 305 | + | let account = json!({ "workspace": workspace, "viewer": viewer }); | |
| 306 | + | match op { | |
| 307 | + | Op::GetUsage => { | |
| 308 | + | let group_by = input["group_by"].as_str().map(str::trim).filter(|by| !by.is_empty()).map(str::to_lowercase); | |
| 309 | + | if let Some(by) = &group_by | |
| 310 | + | && !GROUPS.contains(&by.as_str()) | |
| 311 | + | { | |
| 312 | + | return failed(FailureCode::Invalid, "group_by is product, project or day."); | |
| 313 | + | } | |
| 314 | + | let products = list(input, "products"); | |
| 315 | + | if let Some(unknown) = products.iter().find(|product| !PRODUCTS.contains(&product.as_str())) { | |
| 316 | + | return failed( | |
| 317 | + | FailureCode::Invalid, | |
| 318 | + | &format!("{unknown} is not a product. Give some of {}.", PRODUCTS.join(", ")), | |
| 319 | + | ); | |
| 320 | + | } | |
| 321 | + | let (month_start, today) = this_month(worker::Date::now().as_millis() as f64); | |
| 322 | + | let day = |key: &str, default: String| -> std::result::Result<String, String> { | |
| 323 | + | match input[key].as_str().map(str::trim).filter(|day| !day.is_empty()) { | |
| 324 | + | None => Ok(default), | |
| 325 | + | Some(day) if is_day(day) => Ok(day.to_owned()), | |
| 326 | + | Some(day) => Err(format!("{key} is a day, YYYY-MM-DD, not {day}.")), | |
| 327 | + | } | |
| 328 | + | }; | |
| 329 | + | let (from, until) = match (day("from", month_start), day("until", today)) { | |
| 330 | + | (Ok(from), Ok(until)) => (from, until), | |
| 331 | + | (Err(message), _) | (_, Err(message)) => return failed(FailureCode::Invalid, &message), | |
| 332 | + | }; | |
| 333 | + | let report: Outcome<Value> = g1t_kit::call( | |
| 334 | + | billing, | |
| 335 | + | "usage_report", | |
| 336 | + | &json!({ | |
| 337 | + | "workspace": workspace, | |
| 338 | + | "viewer": viewer, | |
| 339 | + | "from": from, | |
| 340 | + | "until": until, | |
| 341 | + | "products": products, | |
| 342 | + | "projects": list(input, "projects"), | |
| 343 | + | }), | |
| 344 | + | ) | |
| 345 | + | .await?; | |
| 346 | + | shaped(report, &|report| usage_json(report, group_by.as_deref())) | |
| 347 | + | } | |
| 348 | + | Op::GetBudget => shaped(g1t_kit::call(billing, "limit", &account).await?, &budget_json), | |
| 349 | + | Op::SetBudget => { | |
| 350 | + | let actor = match person(viewer, services.scope.is_some()) { | |
| 351 | + | Ok(user) => user, | |
| 352 | + | Err((code, message)) => return failed(code, message), | |
| 353 | + | }; | |
| 354 | + | let current: Outcome<Value> = g1t_kit::call(billing, "limit", &account).await?; | |
| 355 | + | let current = match current { | |
| 356 | + | Outcome::Ok(limit) => limit, | |
| 357 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 358 | + | }; | |
| 359 | + | let change = match budget_change(input, ¤t) { | |
| 360 | + | Ok(change) => change, | |
| 361 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 362 | + | }; | |
| 363 | + | let set: Outcome<Value> = g1t_kit::call( | |
| 364 | + | billing, | |
| 365 | + | "set_budget", | |
| 366 | + | &json!({ | |
| 367 | + | "actor": actor, | |
| 368 | + | "workspace": workspace, | |
| 369 | + | "keepLimit": change.keep_limit, | |
| 370 | + | "amountMicros": change.amount_micros, | |
| 371 | + | "alerts": change.alerts, | |
| 372 | + | "pauseAtLimit": change.pause_at_limit, | |
| 373 | + | "webhook": change.webhook, | |
| 374 | + | }), | |
| 375 | + | ) | |
| 376 | + | .await?; | |
| 377 | + | shaped(set, &budget_json) | |
| 378 | + | } | |
| 379 | + | Op::GetAiCredit => shaped(g1t_kit::call(billing, "ai_credit", &account).await?, &snake), | |
| 380 | + | Op::BuyAiCredit => { | |
| 381 | + | let actor = match person(viewer, services.scope.is_some()) { | |
| 382 | + | Ok(user) => user, | |
| 383 | + | Err((code, message)) => return failed(code, message), | |
| 384 | + | }; | |
| 385 | + | let cents = match &input["amount_cents"] { | |
| 386 | + | Value::Number(number) => number.as_u64(), | |
| 387 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 388 | + | _ => None, | |
| 389 | + | }; | |
| 390 | + | let Some(cents) = cents.and_then(|cents| u32::try_from(cents).ok()).filter(|cents| *cents > 0) else { | |
| 391 | + | return failed(FailureCode::Invalid, "Give amount_cents: the credit in cents, in whole dollars, such as 5000 for $50."); | |
| 392 | + | }; | |
| 393 | + | let return_url = format!("{}/{workspace}/-/billing", services.addresses.site.trim_end_matches('/')); | |
| 394 | + | let checkout: Outcome<Value> = g1t_kit::call( | |
| 395 | + | billing, | |
| 396 | + | "buy_ai_credit", | |
| 397 | + | &json!({ "actor": actor, "workspace": workspace, "amountCents": cents, "returnUrl": return_url }), | |
| 398 | + | ) | |
| 399 | + | .await?; | |
| 400 | + | shaped(checkout, &|checkout| json!({ "url": checkout["url"] })) | |
| 401 | + | } | |
| 402 | + | Op::ListInvoices => { | |
| 403 | + | let details: Outcome<Value> = g1t_kit::call(billing, "billing_details", &account).await?; | |
| 404 | + | let details = match details { | |
| 405 | + | Outcome::Ok(details) => details, | |
| 406 | + | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 407 | + | }; | |
| 408 | + | let usage: Outcome<Vec<Value>> = g1t_kit::call(billing, "invoices", &account).await?; | |
| 409 | + | Ok(match usage { | |
| 410 | + | Outcome::Ok(usage) => Outcome::Ok(invoices_json(&details, &usage)), | |
| 411 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 412 | + | }) | |
| 413 | + | } | |
| 414 | + | Op::GetBillingDetails => shaped(g1t_kit::call(billing, "billing_details", &account).await?, &details_json), | |
| 415 | + | _ => failed(FailureCode::Invalid, "Not a billing operation."), | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | #[cfg(test)] | |
| 420 | + | mod tests { | |
| 421 | + | use super::*; | |
| 422 | + | ||
| 423 | + | #[test] | |
| 424 | + | fn a_usage_report_is_snake_case_all_the_way_down() { | |
| 425 | + | let report = json!({ | |
| 426 | + | "from": "2026-10-01", "until": "2026-10-07", | |
| 427 | + | "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000, | |
| 428 | + | "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 }, | |
| 429 | + | "days": [ | |
| 430 | + | { "day": "2026-10-02", "product": "agent", "micros": 4_000_000 }, | |
| 431 | + | { "day": "2026-10-02", "product": "sandboxes", "micros": 500_000 }, | |
| 432 | + | { "day": "2026-10-01", "product": "agent", "micros": 8_000_000 }, | |
| 433 | + | ], | |
| 434 | + | "products": [ | |
| 435 | + | { "key": "agent", "label": "Agent", "micros": 12_000_000, "features": [{ "key": "runs", "label": "Runs", "micros": 12_000_000, "count": 3 }], | |
| 436 | + | "meters": [{ "key": "agent_models", "label": "Models", "product": "agent", "unit": "tokens", "quantity": 1.5e6, | |
| 437 | + | "micros": 12_000_000, "pendingMicros": 0, "daily": [8_000_000, 4_000_000], "allowance": null, | |
| 438 | + | "byProject": [{ "project": "acme/web", "micros": 9_000_000, "quantity": 1e6 }, | |
| 439 | + | { "project": "", "micros": 3_000_000, "quantity": 5e5 }] }] }, | |
| 440 | + | { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [], | |
| 441 | + | "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0, | |
| 442 | + | "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000], | |
| 443 | + | "allowance": { "used": 600.0, "of": 3600.0, "unit": "seconds" }, | |
| 444 | + | "byProject": [{ "project": "acme/web", "micros": 500_000, "quantity": 600.0 }] }] }, | |
| 445 | + | ], | |
| 446 | + | "projects": ["acme/web"], "included": null, "discountPercent": null, | |
| 447 | + | "aiCreditMicros": 40_000_000, "creditMicros": 0, "trialMicros": null, "plan": "pro", "free": false, | |
| 448 | + | }); | |
| 449 | + | let usage = usage_json(&report, None); | |
| 450 | + | assert_eq!(usage["totals"]["charged_micros"], 10_000_000); | |
| 451 | + | assert_eq!(usage["products"][0]["meters"][0]["by_project"][0]["project"], "acme/web"); | |
| 452 | + | assert_eq!(usage["products"][0]["meters"][0]["pending_micros"], 0); | |
| 453 | + | assert_eq!(usage["ai_credit_micros"], 40_000_000); | |
| 454 | + | assert!(usage.get("groups").is_none()); | |
| 455 | + | let text = usage.to_string(); | |
| 456 | + | for camel in ["Micros", "byProject", "discountPercent"] { | |
| 457 | + | assert!(!text.contains(camel), "{camel} in {text}"); | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | let by_project = usage_json(&report, Some("project")); | |
| 461 | + | assert_eq!(by_project["group_by"], "project"); | |
| 462 | + | assert_eq!( | |
| 463 | + | by_project["groups"], | |
| 464 | + | json!([{ "key": "acme/web", "micros": 9_500_000 }, { "key": null, "micros": 3_000_000 }]) | |
| 465 | + | ); | |
| 466 | + | let by_day = usage_json(&report, Some("day")); | |
| 467 | + | assert_eq!(by_day["groups"], json!([{ "key": "2026-10-01", "micros": 8_000_000 }, { "key": "2026-10-02", "micros": 4_500_000 }])); | |
| 468 | + | let by_product = usage_json(&report, Some("product")); | |
| 469 | + | assert_eq!(by_product["groups"][1], json!({ "key": "sandboxes", "label": "Sandboxes", "micros": 500_000 })); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | #[test] | |
| 473 | + | fn ai_credit_is_snake_case() { | |
| 474 | + | let credit = json!({ | |
| 475 | + | "balanceMicros": 42_000_000, "purchasedMicros": 40_000_000, "givenMicros": 2_000_000, | |
| 476 | + | "grants": [{ "id": "crd_1", "kind": "purchase", "amountMicros": 40_000_000, "usedMicros": 0, "leftMicros": 40_000_000, "expiresAt": null }], | |
| 477 | + | "freeViaDiscount": false, "postpaid": false, "blocked": false, "canBuy": true, | |
| 478 | + | "presetsCents": [2500, 5000], "minCents": 1000, "maxCents": 100_000, | |
| 479 | + | "cardFee": { "on": true, "percentMicros": 29_000.0, "fixedCents": 30 }, | |
| 480 | + | "reload": { "enabled": false, "thresholdMicros": 0, "targetMicros": 0, "monthlyMaxMicros": 0, "reloadedMicros": 0, "failedAt": null, "error": null }, | |
| 481 | + | "agentRateMicros": 3.6, "modelMarkupPercent": 10, "gatewayMarkupPercent": 5, "upgradeCreditMicros": 0, "expiresDays": 365, | |
| 482 | + | }); | |
| 483 | + | let out = snake(&credit); | |
| 484 | + | assert_eq!(out["balance_micros"], 42_000_000); | |
| 485 | + | assert_eq!(out["grants"][0]["left_micros"], 40_000_000); | |
| 486 | + | assert_eq!(out["card_fee"]["fixed_cents"], 30); | |
| 487 | + | assert_eq!(out["reload"]["monthly_max_micros"], 0); | |
| 488 | + | assert_eq!(out["can_buy"], true); | |
| 489 | + | assert!(out.get("balanceMicros").is_none()); | |
| 490 | + | assert_eq!(snake_key("line1"), "line1"); | |
| 491 | + | assert_eq!(snake_key("last4"), "last4"); | |
| 492 | + | assert_eq!(snake_key("taxIdType"), "tax_id_type"); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | #[test] | |
| 496 | + | fn agents_and_workspace_tokens_never_change_billing() { | |
| 497 | + | let person_user = User { username: "ana".into(), ..User::default() }; | |
| 498 | + | assert!(person(&Some(person_user.clone()), false).is_ok()); | |
| 499 | + | // A person's token used by an agent's run is still an agent's. | |
| 500 | + | assert_eq!(person(&Some(person_user), true).unwrap_err().0, FailureCode::Forbidden); | |
| 501 | + | let agent = User { username: "g1t".into(), kind: PrincipalKind::Agent, ..User::default() }; | |
| 502 | + | let (code, message) = person(&Some(agent), false).unwrap_err(); | |
| 503 | + | assert_eq!(code, FailureCode::Forbidden); | |
| 504 | + | assert!(message.contains("agents never change billing")); | |
| 505 | + | let workspace = User { username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() }; | |
| 506 | + | let (code, message) = person(&Some(workspace), false).unwrap_err(); | |
| 507 | + | assert_eq!(code, FailureCode::Forbidden); | |
| 508 | + | assert!(message.contains("personal access token")); | |
| 509 | + | assert_eq!(person(&None, false).unwrap_err().0, FailureCode::Unauthenticated); | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn a_budget_change_keeps_what_was_not_given() { | |
| 514 | + | let current = json!({ | |
| 515 | + | "workspace": "acme", "spendLimitMicros": 300_000_000, "defaultSpendLimit": false, "spentMicros": 12_000_000, | |
| 516 | + | "availableMicros": 1_000_000_000, "alertLevels": [100, 75, 50], "pauseAtLimit": true, | |
| 517 | + | "budgetWebhook": "https://acme.dev/hooks/budget", "state": "ok", "message": null, | |
| 518 | + | }); | |
| 519 | + | let kept = budget_change(&json!({}), ¤t).unwrap(); | |
| 520 | + | assert_eq!( | |
| 521 | + | kept, | |
| 522 | + | BudgetChange { keep_limit: true, amount_micros: Some(300_000_000), alerts: vec![100, 75, 50], pause_at_limit: true, webhook: Some("https://acme.dev/hooks/budget".into()) } | |
| 523 | + | ); | |
| 524 | + | let changed = budget_change(&json!({ "amount_micros": null, "alerts": [90, 50, 90], "pause_at_limit": false, "webhook": null }), ¤t).unwrap(); | |
| 525 | + | assert_eq!(changed, BudgetChange { keep_limit: false, amount_micros: None, alerts: vec![50, 90], pause_at_limit: false, webhook: None }); | |
| 526 | + | for bad in [json!({ "alerts": [60] }), json!({ "alerts": "50" }), json!({ "amount_micros": -1 }), json!({ "webhook": "http://x" }), json!({ "pause_at_limit": "yes" })] { | |
| 527 | + | assert!(budget_change(&bad, ¤t).is_err(), "{bad}"); | |
| 528 | + | } | |
| 529 | + | // The automatic limit stays automatic when no amount is given. | |
| 530 | + | let automatic = json!({ "spendLimitMicros": 200_000_000, "defaultSpendLimit": true }); | |
| 531 | + | assert_eq!(budget_change(&json!({}), &automatic).unwrap().amount_micros, None); | |
| 532 | + | let budget = budget_json(¤t); | |
| 533 | + | assert_eq!(budget["amount_micros"], 300_000_000); | |
| 534 | + | assert_eq!(budget["max_amount_micros"], 1_000_000_000); | |
| 535 | + | assert_eq!(budget["alerts"], json!([100, 75, 50])); | |
| 536 | + | assert_eq!(budget["automatic"], false); | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn invoices_put_every_invoice_beside_the_itemised_usage_ones() { | |
| 541 | + | let details = json!({ | |
| 542 | + | "customer": true, "email": "billing@acme.dev", | |
| 543 | + | "invoices": [{ "id": "in_1", "number": "ACME-0001", "status": "paid", "totalCents": 2000, "currency": "usd", | |
| 544 | + | "createdAt": "2026-10-01T00:00:00Z", "description": null, "hostedUrl": null, "pdfUrl": null }], | |
| 545 | + | "upcoming": { "closesAt": "2026-11-01T00:00:00Z", "subscriptionsMicros": 20_000_000, "usageMicros": 5_000_000, "totalMicros": 25_000_000 }, | |
| 546 | + | "unavailable": null, | |
| 547 | + | }); | |
| 548 | + | let usage = [json!({ "invoiceId": "inv_1", "workspace": "acme", "reason": "month", "period": "2026-09", "amountMicros": 5_000_000, | |
| 549 | + | "status": "paid", "hostedUrl": null, "pdfUrl": null, "lines": [{ "description": "Agent", "amountMicros": 5_000_000 }], | |
| 550 | + | "createdAt": "2026-10-01T00:00:00Z" })]; | |
| 551 | + | let out = invoices_json(&details, &usage); | |
| 552 | + | assert_eq!(out["invoices"][0]["total_cents"], 2000); | |
| 553 | + | assert_eq!(out["usage_invoices"][0]["id"], "inv_1"); | |
| 554 | + | assert!(out["usage_invoices"][0].get("invoice_id").is_none()); | |
| 555 | + | assert_eq!(out["usage_invoices"][0]["lines"][0]["amount_micros"], 5_000_000); | |
| 556 | + | assert_eq!(out["upcoming"]["total_micros"], 25_000_000); | |
| 557 | + | let shown = details_json(&details); | |
| 558 | + | assert_eq!(shown["email"], "billing@acme.dev"); | |
| 559 | + | assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none()); | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | const OPS: [Op; 7] = | |
| 563 | + | [Op::GetUsage, Op::GetBudget, Op::SetBudget, Op::GetAiCredit, Op::BuyAiCredit, Op::ListInvoices, Op::GetBillingDetails]; | |
| 564 | + | ||
| 565 | + | /// Billing belongs to a workspace, needs someone signed in, and is one | |
| 566 | + | /// MCP tool whose writes no preset but full access reaches. | |
| 567 | + | #[test] | |
| 568 | + | fn billing_operations_name_a_workspace_and_agents_only_read() { | |
| 569 | + | use crate::tools::{Gate, Tool}; | |
| 570 | + | use g1t_contracts::scopes::{Preset, TokenAccess, scope_for}; | |
| 571 | + | for op in OPS { | |
| 572 | + | assert!(!op.needs_repo(), "{}", op.name()); | |
| 573 | + | assert!(op.needs_user(), "{}", op.name()); | |
| 574 | + | assert!(op.required().contains(&"workspace".to_owned()), "{}", op.name()); | |
| 575 | + | assert!(scope_for(op.name()).is_some(), "{}", op.name()); | |
| 576 | + | } | |
| 577 | + | let tool = Tool::by_name("billing").unwrap(); | |
| 578 | + | let token = |preset: Preset| TokenAccess { | |
| 579 | + | token_id: "tok_1".into(), | |
| 580 | + | scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 581 | + | legacy: false, | |
| 582 | + | }; | |
| 583 | + | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 584 | + | let access = token(preset); | |
| 585 | + | let seen: Vec<&str> = tool.visible(&Gate::Token(&access)).iter().map(|action| action.name).collect(); | |
| 586 | + | assert_eq!(seen, ["usage", "budget", "ai_credit", "invoices", "billing_details"], "{}", preset.as_str()); | |
| 587 | + | } | |
| 588 | + | let full = TokenAccess::full(); | |
| 589 | + | assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len()); | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | #[test] | |
| 593 | + | fn the_month_so_far_is_read_from_the_clock() { | |
| 594 | + | // 2026-10-07T12:00:00Z. | |
| 595 | + | assert_eq!(this_month(1_791_374_400_000.0), ("2026-10-01".to_owned(), "2026-10-07".to_owned())); | |
| 596 | + | assert_eq!(this_month(0.0), ("1970-01-01".to_owned(), "1970-01-01".to_owned())); | |
| 597 | + | // 2024-02-29. | |
| 598 | + | assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned())); | |
| 599 | + | assert!(is_day("2026-10-07") && !is_day("2026-10-7") && !is_day("20261007xx")); | |
| 600 | + | assert_eq!(list(&json!({ "products": "agent, sandboxes,," }), "products"), vec!["agent", "sandboxes"]); | |
| 601 | + | assert_eq!(list(&json!({ "products": ["agent"] }), "products"), vec!["agent"]); | |
| 602 | + | } | |
| 603 | + | } |
| 7 | 7 | mod addresses; | |
| 8 | 8 | mod alerts; | |
| 9 | 9 | mod audit; | |
| 10 | + | mod billing; | |
| 10 | 11 | mod blobs; | |
| 11 | 12 | mod mcp; | |
| 12 | 13 | mod notifications; |
| 62 | 62 | ], | |
| 63 | 63 | ), | |
| 64 | 64 | ( | |
| 65 | + | "Billing", | |
| 66 | + | "A workspace's usage, its budget, its AI credit and its invoices. Members read them; owners change the budget and buy credit, as people. g1t's agents never change billing.", | |
| 67 | + | &[ | |
| 68 | + | Op::GetUsage, | |
| 69 | + | Op::GetBudget, | |
| 70 | + | Op::SetBudget, | |
| 71 | + | Op::GetAiCredit, | |
| 72 | + | Op::BuyAiCredit, | |
| 73 | + | Op::ListInvoices, | |
| 74 | + | Op::GetBillingDetails, | |
| 75 | + | ], | |
| 76 | + | ), | |
| 77 | + | ( | |
| 65 | 78 | "Repositories", | |
| 66 | 79 | "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it.", | |
| 67 | 80 | &[ | |
| ⋯ | |||
| 492 | 505 | Op::DeleteRepoSubscription => "Stop watching a repository", | |
| 493 | 506 | Op::ListWatchedRepos => "List repositories you watch", | |
| 494 | 507 | Op::ListPinnedProjects => "List your pinned projects", | |
| 508 | + | Op::GetUsage => "Get a workspace's usage", | |
| 509 | + | Op::GetBudget => "Get a workspace's budget", | |
| 510 | + | Op::SetBudget => "Change a workspace's budget", | |
| 511 | + | Op::GetAiCredit => "Get a workspace's AI credit", | |
| 512 | + | Op::BuyAiCredit => "Buy AI credit", | |
| 513 | + | Op::ListInvoices => "List a workspace's invoices", | |
| 514 | + | Op::GetBillingDetails => "Get a workspace's billing details", | |
| 495 | 515 | Op::PinProject => "Pin a project", | |
| 496 | 516 | Op::UnpinProject => "Unpin a project", | |
| 497 | 517 | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 249 | 249 | RemoveTeamRepo, | |
| 250 | 250 | SetTeamReviewAssignment, | |
| 251 | 251 | ListUserTeams, | |
| 252 | + | GetUsage, | |
| 253 | + | GetBudget, | |
| 254 | + | SetBudget, | |
| 255 | + | GetAiCredit, | |
| 256 | + | BuyAiCredit, | |
| 257 | + | ListInvoices, | |
| 258 | + | GetBillingDetails, | |
| 252 | 259 | RequestReviewers, | |
| 253 | 260 | RemoveRequestedReviewers, | |
| 254 | 261 | GetCodeownersErrors, | |
| ⋯ | |||
| 616 | 623 | } | |
| 617 | 624 | ||
| 618 | 625 | impl Op { | |
| 619 | − | pub const ALL: [Op; 197] = [ | |
| 626 | + | pub const ALL: [Op; 204] = [ | |
| 620 | 627 | Op::Whoami, | |
| 621 | 628 | Op::CreateWorkspace, | |
| 622 | 629 | Op::DeleteWorkspace, | |
| ⋯ | |||
| 780 | 787 | Op::RemoveTeamRepo, | |
| 781 | 788 | Op::SetTeamReviewAssignment, | |
| 782 | 789 | Op::ListUserTeams, | |
| 790 | + | Op::GetUsage, | |
| 791 | + | Op::GetBudget, | |
| 792 | + | Op::SetBudget, | |
| 793 | + | Op::GetAiCredit, | |
| 794 | + | Op::BuyAiCredit, | |
| 795 | + | Op::ListInvoices, | |
| 796 | + | Op::GetBillingDetails, | |
| 783 | 797 | Op::RequestReviewers, | |
| 784 | 798 | Op::RemoveRequestedReviewers, | |
| 785 | 799 | Op::GetCodeownersErrors, | |
| ⋯ | |||
| 986 | 1000 | Op::RemoveTeamRepo => "remove_team_repo", | |
| 987 | 1001 | Op::SetTeamReviewAssignment => "set_team_review_assignment", | |
| 988 | 1002 | Op::ListUserTeams => "list_user_teams", | |
| 1003 | + | Op::GetUsage => "get_usage", | |
| 1004 | + | Op::GetBudget => "get_budget", | |
| 1005 | + | Op::SetBudget => "set_budget", | |
| 1006 | + | Op::GetAiCredit => "get_ai_credit", | |
| 1007 | + | Op::BuyAiCredit => "buy_ai_credit", | |
| 1008 | + | Op::ListInvoices => "list_invoices", | |
| 1009 | + | Op::GetBillingDetails => "get_billing_details", | |
| 989 | 1010 | Op::RequestReviewers => "request_reviewers", | |
| 990 | 1011 | Op::RemoveRequestedReviewers => "remove_requested_reviewers", | |
| 991 | 1012 | Op::GetCodeownersErrors => "get_codeowners_errors", | |
| ⋯ | |||
| 1447 | 1468 | Op::SetTeamReviewAssignment => { | |
| 1448 | 1469 | "Choose what happens when a team is asked to review a pull request. Off, everyone in it is asked. On (`enabled`), g1t picks `count` people from it (1 to 10, never the pull request's author) and asks them, and the team stays shown as asked beside them: `round_robin` picks whoever this team asked least recently, `load_balance` whoever has the fewest pull requests waiting on their review. `skip_busy` leaves out anyone with `busy_at` or more waiting; `include_child_teams` also picks from its child teams' people; `excluded` lists usernames never picked; `notify_team` also tells the rest of the team. Fields left out keep their current value. Owners of the workspace and the team's maintainers. People only. Returns the team." | |
| 1449 | 1470 | } | |
| 1471 | + | Op::GetUsage => { | |
| 1472 | + | "A workspace's usage over a range of days, at price, and what paid for it. `from` and `until` are UTC days, `YYYY-MM-DD`, with `until` included and at most 400 days in all; left out, the current month so far. `products` narrows it to product families (agent, sandboxes, gateway, deployments, git_storage, packages, security, search) and `projects` to repositories (\"owner/name\"). Returns `totals`: `price_micros` less `discount_micros`, `included_micros` and `credits_micros` is `charged_micros`, what is left for the workspace to pay; `pending_micros` is metered this month and charged when it closes; `cost_micros` is what it cost g1t. Then `days` (each day and product with usage), `products` (every family, with its meters: quantity, unit, amount, a `daily` amount for each day of the range, any `allowance` and the split `by_project`), `projects` (every repository with usage in the range), and the AI credit and other credit left now. With `group_by` (`product`, `project` or `day`), `groups` adds up the range that way. Amounts are whole millionths of a dollar. Members of the workspace only." | |
| 1473 | + | } | |
| 1474 | + | Op::GetBudget => { | |
| 1475 | + | "A workspace's budget: its monthly spend limit (`amount_micros`; `automatic` is true while the owners have not set one, and it is then $200 or twice last month's spend), what was charged this month (`spent_micros`), the most the owners may set it to themselves (`max_amount_micros`), its `alerts` (percent of the limit, each emailed to the owners once a month), whether usage pauses at the limit (`pause_at_limit`), the `webhook` told of each alert, and `state`: `ok`, `warning` or `stopped`, with a `message` when work is stopped or close to it. Members of the workspace only." | |
| 1476 | + | } | |
| 1477 | + | Op::SetBudget => { | |
| 1478 | + | "Change a workspace's budget. Give only what you change; the rest stays as it is. `amount_micros` is the monthly spend limit, up to `max_amount_micros`, or null for the automatic one. `alerts` is some of 50, 75, 90 and 100, in percent of the limit. `pause_at_limit` false makes the limit alert only, without pausing usage; g1t's own ceiling still applies. `webhook` is an https:// address sent a JSON POST for each alert, or null for none. Owners only, as a person: signed in or with a personal access token. A workspace's own token and g1t's agents can read the budget but never change it. Returns the budget." | |
| 1479 | + | } | |
| 1480 | + | Op::GetAiCredit => { | |
| 1481 | + | "A workspace's AI credit, which pays for agent and AI gateway usage: what is left (`balance_micros`), how much of it was bought and given, its `grants` newest first, whether new runs on g1t's models are refused for want of it (`blocked`), whether it can be bought (`can_buy`) and for how much (`min_cents`, `max_cents`, `presets_cents`, and the `card_fee` added on top), auto-reload, the agent rate and the markups on models. `free_via_discount` or `postpaid` mean no credit is needed. Members of the workspace only." | |
| 1482 | + | } | |
| 1483 | + | Op::BuyAiCredit => { | |
| 1484 | + | "Start buying AI credit. Returns `url`, a payment page to open in a browser and pay by card; it comes back to the workspace's billing page. `amount_cents` is the credit, in whole dollars from $10 (1000) to $1,000 (100000); any card fee is added on top. The credit is added once the payment goes through. Owners only, as a person: signed in or with a personal access token. A workspace's own token and g1t's agents never buy credit." | |
| 1485 | + | } | |
| 1486 | + | Op::ListInvoices => { | |
| 1487 | + | "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, activations, AI credit and usage), each with its `status`, `total_cents`, `currency` and links to view it and its PDF. `usage_invoices` are g1t's itemised invoices for usage, one when each month closes and one each time the card is charged near the limit, with their `lines` in millionths of a dollar. `upcoming` is what the next invoice comes to so far. `unavailable` says why `invoices` could not be read just now, when it could not. Members of the workspace only." | |
| 1488 | + | } | |
| 1489 | + | Op::GetBillingDetails => { | |
| 1490 | + | "Who a workspace's invoices are made out to: the billing `email`, `name`, `address`, tax ID (`tax_id_type`, `tax_id`), `po_number` and the invoices' `language`, with the default `payment_method` as far as it is safe to show (its kind, brand, last four digits and expiry). `customer` is false until the workspace has been set up to pay. Members of the workspace only." | |
| 1491 | + | } | |
| 1450 | 1492 | Op::ListUserTeams => { | |
| 1451 | 1493 | "The teams someone is in within a workspace, as list_teams describes them, leaving out secret teams you cannot see. Members of the workspace only." | |
| 1452 | 1494 | } | |
| ⋯ | |||
| 2673 | 2715 | object(properties, &required) | |
| 2674 | 2716 | } | |
| 2675 | 2717 | Op::SetTeamReviewAssignment => object(team_target(review_assignment_properties()), &["workspace", "team"]), | |
| 2718 | + | Op::GetUsage => object( | |
| 2719 | + | json!({ | |
| 2720 | + | "workspace": workspace_schema(), | |
| 2721 | + | "from": { "type": "string", "format": "date", "description": "The first day, YYYY-MM-DD (UTC). The first of this month if not given." }, | |
| 2722 | + | "until": { "type": "string", "format": "date", "description": "The last day, included, YYYY-MM-DD (UTC). Today if not given." }, | |
| 2723 | + | "products": { | |
| 2724 | + | "type": "array", | |
| 2725 | + | "items": { "type": "string", "enum": crate::billing::PRODUCTS }, | |
| 2726 | + | "description": "Only these product families; all of them if not given. In a query string, separate them with commas.", | |
| 2727 | + | }, | |
| 2728 | + | "projects": { | |
| 2729 | + | "type": "array", | |
| 2730 | + | "items": { "type": "string" }, | |
| 2731 | + | "description": "Only these repositories, as \"owner/name\"; all of them if not given. In a query string, separate them with commas.", | |
| 2732 | + | }, | |
| 2733 | + | "group_by": { | |
| 2734 | + | "type": "string", | |
| 2735 | + | "enum": crate::billing::GROUPS, | |
| 2736 | + | "description": "Also add up the range by product, project or day, as `groups`.", | |
| 2737 | + | }, | |
| 2738 | + | }), | |
| 2739 | + | &["workspace"], | |
| 2740 | + | ), | |
| 2741 | + | Op::GetBudget | Op::GetAiCredit | Op::ListInvoices | Op::GetBillingDetails => { | |
| 2742 | + | object(json!({ "workspace": workspace_schema() }), &["workspace"]) | |
| 2743 | + | } | |
| 2744 | + | Op::SetBudget => object( | |
| 2745 | + | json!({ | |
| 2746 | + | "workspace": workspace_schema(), | |
| 2747 | + | "amount_micros": { | |
| 2748 | + | "type": ["integer", "null"], | |
| 2749 | + | "minimum": 0, | |
| 2750 | + | "description": "The monthly spend limit, in millionths of a dollar: 500000000 is $500. Null for the automatic limit. Left out: unchanged.", | |
| 2751 | + | }, | |
| 2752 | + | "alerts": { | |
| 2753 | + | "type": "array", | |
| 2754 | + | "items": { "type": "integer", "enum": crate::billing::ALERT_LEVELS }, | |
| 2755 | + | "description": "When to alert, in percent of the limit: some of 50, 75, 90 and 100. Replaces the whole list. Left out: unchanged.", | |
| 2756 | + | }, | |
| 2757 | + | "pause_at_limit": { "type": "boolean", "description": "Pause usage at the limit (the default), or with false, only alert. Left out: unchanged." }, | |
| 2758 | + | "webhook": { | |
| 2759 | + | "type": ["string", "null"], | |
| 2760 | + | "description": "An https:// address sent a JSON POST for each alert, or null for none. Left out: unchanged.", | |
| 2761 | + | }, | |
| 2762 | + | }), | |
| 2763 | + | &["workspace"], | |
| 2764 | + | ), | |
| 2765 | + | Op::BuyAiCredit => object( | |
| 2766 | + | json!({ | |
| 2767 | + | "workspace": workspace_schema(), | |
| 2768 | + | "amount_cents": { | |
| 2769 | + | "type": "integer", | |
| 2770 | + | "minimum": 1000, | |
| 2771 | + | "maximum": 100000, | |
| 2772 | + | "multipleOf": 100, | |
| 2773 | + | "description": "The credit to buy, in cents, in whole dollars: 5000 is $50.", | |
| 2774 | + | }, | |
| 2775 | + | }), | |
| 2776 | + | &["workspace", "amount_cents"], | |
| 2777 | + | ), | |
| 2676 | 2778 | Op::ListUserTeams => object( | |
| 2677 | 2779 | json!({ "workspace": workspace_schema(), "username": username_schema() }), | |
| 2678 | 2780 | &["workspace", "username"], | |
| ⋯ | |||
| 2813 | 2915 | | Op::RemoveTeamRepo | |
| 2814 | 2916 | | Op::SetTeamReviewAssignment | |
| 2815 | 2917 | | Op::ListUserTeams | |
| 2918 | + | | Op::GetUsage | |
| 2919 | + | | Op::GetBudget | |
| 2920 | + | | Op::SetBudget | |
| 2921 | + | | Op::GetAiCredit | |
| 2922 | + | | Op::BuyAiCredit | |
| 2923 | + | | Op::ListInvoices | |
| 2924 | + | | Op::GetBillingDetails | |
| 2816 | 2925 | ) | |
| 2817 | 2926 | } | |
| 2818 | 2927 | ||
| ⋯ | |||
| 4565 | 4674 | ) | |
| 4566 | 4675 | .await | |
| 4567 | 4676 | } | |
| 4677 | + | // A workspace's billing: the billing service decides, this gives | |
| 4678 | + | // each answer its public shape. | |
| 4679 | + | Op::GetUsage | |
| 4680 | + | | Op::GetBudget | |
| 4681 | + | | Op::SetBudget | |
| 4682 | + | | Op::GetAiCredit | |
| 4683 | + | | Op::BuyAiCredit | |
| 4684 | + | | Op::ListInvoices | |
| 4685 | + | | Op::GetBillingDetails => crate::billing::run(self, services, viewer, input).await, | |
| 4568 | 4686 | Op::ListUserTeams => { | |
| 4569 | 4687 | pass( | |
| 4570 | 4688 | identity, | |
| 5634 | 5634 | } | |
| 5635 | 5635 | ] | |
| 5636 | 5636 | }, | |
| 5637 | + | "get_usage": { | |
| 5638 | + | "params": { | |
| 5639 | + | "workspace": "flagon-io" | |
| 5640 | + | }, | |
| 5641 | + | "query": { | |
| 5642 | + | "from": "2026-10-01", | |
| 5643 | + | "until": "2026-10-07", | |
| 5644 | + | "group_by": "project" | |
| 5645 | + | }, | |
| 5646 | + | "response": { | |
| 5647 | + | "from": "2026-10-01", | |
| 5648 | + | "until": "2026-10-07", | |
| 5649 | + | "totals": { | |
| 5650 | + | "price_micros": 48210000, | |
| 5651 | + | "discount_micros": 0, | |
| 5652 | + | "included_micros": 20000000, | |
| 5653 | + | "credits_micros": 18000000, | |
| 5654 | + | "charged_micros": 10210000, | |
| 5655 | + | "pending_micros": 1340000, | |
| 5656 | + | "cost_micros": 40100000 | |
| 5657 | + | }, | |
| 5658 | + | "days": [ | |
| 5659 | + | { | |
| 5660 | + | "day": "2026-10-06", | |
| 5661 | + | "product": "agent", | |
| 5662 | + | "micros": 12400000 | |
| 5663 | + | }, | |
| 5664 | + | { | |
| 5665 | + | "day": "2026-10-06", | |
| 5666 | + | "product": "sandboxes", | |
| 5667 | + | "micros": 2100000 | |
| 5668 | + | }, | |
| 5669 | + | { | |
| 5670 | + | "day": "2026-10-07", | |
| 5671 | + | "product": "agent", | |
| 5672 | + | "micros": 9800000 | |
| 5673 | + | } | |
| 5674 | + | ], | |
| 5675 | + | "products": [ | |
| 5676 | + | { | |
| 5677 | + | "key": "agent", | |
| 5678 | + | "label": "Agent", | |
| 5679 | + | "micros": 41000000, | |
| 5680 | + | "meters": [ | |
| 5681 | + | { | |
| 5682 | + | "key": "agent_models", | |
| 5683 | + | "label": "Models", | |
| 5684 | + | "product": "agent", | |
| 5685 | + | "unit": "tokens", | |
| 5686 | + | "quantity": 9120000, | |
| 5687 | + | "micros": 41000000, | |
| 5688 | + | "pending_micros": 0, | |
| 5689 | + | "daily": [ | |
| 5690 | + | 3100000, | |
| 5691 | + | 5200000, | |
| 5692 | + | 4400000, | |
| 5693 | + | 6000000, | |
| 5694 | + | 0, | |
| 5695 | + | 12400000, | |
| 5696 | + | 9800000 | |
| 5697 | + | ], | |
| 5698 | + | "allowance": null, | |
| 5699 | + | "by_project": [ | |
| 5700 | + | { | |
| 5701 | + | "project": "flagon-io/g1t", | |
| 5702 | + | "micros": 36000000, | |
| 5703 | + | "quantity": 8010000 | |
| 5704 | + | }, | |
| 5705 | + | { | |
| 5706 | + | "project": "flagon-io/hello", | |
| 5707 | + | "micros": 5000000, | |
| 5708 | + | "quantity": 1110000 | |
| 5709 | + | } | |
| 5710 | + | ] | |
| 5711 | + | } | |
| 5712 | + | ], | |
| 5713 | + | "features": [ | |
| 5714 | + | { | |
| 5715 | + | "key": "runs", | |
| 5716 | + | "label": "Runs", | |
| 5717 | + | "micros": 33000000, | |
| 5718 | + | "count": 14 | |
| 5719 | + | }, | |
| 5720 | + | { | |
| 5721 | + | "key": "reviews", | |
| 5722 | + | "label": "Reviews", | |
| 5723 | + | "micros": 8000000, | |
| 5724 | + | "count": 9 | |
| 5725 | + | } | |
| 5726 | + | ] | |
| 5727 | + | }, | |
| 5728 | + | { | |
| 5729 | + | "key": "sandboxes", | |
| 5730 | + | "label": "Sandboxes", | |
| 5731 | + | "micros": 7210000, | |
| 5732 | + | "meters": [ | |
| 5733 | + | { | |
| 5734 | + | "key": "sandbox", | |
| 5735 | + | "label": "Sandbox time", | |
| 5736 | + | "product": "sandboxes", | |
| 5737 | + | "unit": "seconds", | |
| 5738 | + | "quantity": 41300, | |
| 5739 | + | "micros": 7210000, | |
| 5740 | + | "pending_micros": 0, | |
| 5741 | + | "daily": [ | |
| 5742 | + | 900000, | |
| 5743 | + | 1200000, | |
| 5744 | + | 800000, | |
| 5745 | + | 1100000, | |
| 5746 | + | 0, | |
| 5747 | + | 2100000, | |
| 5748 | + | 1110000 | |
| 5749 | + | ], | |
| 5750 | + | "allowance": { | |
| 5751 | + | "used": 41300, | |
| 5752 | + | "of": 108000, | |
| 5753 | + | "unit": "seconds" | |
| 5754 | + | }, | |
| 5755 | + | "by_project": [ | |
| 5756 | + | { | |
| 5757 | + | "project": "flagon-io/g1t", | |
| 5758 | + | "micros": 7210000, | |
| 5759 | + | "quantity": 41300 | |
| 5760 | + | } | |
| 5761 | + | ] | |
| 5762 | + | } | |
| 5763 | + | ], | |
| 5764 | + | "features": [] | |
| 5765 | + | } | |
| 5766 | + | ], | |
| 5767 | + | "projects": [ | |
| 5768 | + | "flagon-io/g1t", | |
| 5769 | + | "flagon-io/hello" | |
| 5770 | + | ], | |
| 5771 | + | "included": { | |
| 5772 | + | "used": 20, | |
| 5773 | + | "of": 20, | |
| 5774 | + | "unit": "dollars" | |
| 5775 | + | }, | |
| 5776 | + | "discount_percent": null, | |
| 5777 | + | "ai_credit_micros": 62000000, | |
| 5778 | + | "credit_micros": 0, | |
| 5779 | + | "trial_micros": null, | |
| 5780 | + | "plan": "pro", | |
| 5781 | + | "free": false, | |
| 5782 | + | "group_by": "project", | |
| 5783 | + | "groups": [ | |
| 5784 | + | { | |
| 5785 | + | "key": "flagon-io/g1t", | |
| 5786 | + | "micros": 43210000 | |
| 5787 | + | }, | |
| 5788 | + | { | |
| 5789 | + | "key": "flagon-io/hello", | |
| 5790 | + | "micros": 5000000 | |
| 5791 | + | } | |
| 5792 | + | ] | |
| 5793 | + | }, | |
| 5794 | + | "notes": "Every product family is listed, in order, even with nothing used; this example shows two. `daily` has one amount for each day of the range, oldest first. `projects` and `products` take several values separated by commas: `?products=agent,sandboxes`. A range of more than 400 days, or one that ends before it starts, is refused with `invalid`." | |
| 5795 | + | }, | |
| 5796 | + | "get_budget": { | |
| 5797 | + | "params": { | |
| 5798 | + | "workspace": "flagon-io" | |
| 5799 | + | }, | |
| 5800 | + | "response": { | |
| 5801 | + | "workspace": "flagon-io", | |
| 5802 | + | "amount_micros": 500000000, | |
| 5803 | + | "automatic": false, | |
| 5804 | + | "spent_micros": 132450000, | |
| 5805 | + | "max_amount_micros": 2000000000, | |
| 5806 | + | "alerts": [ | |
| 5807 | + | 50, | |
| 5808 | + | 75, | |
| 5809 | + | 90, | |
| 5810 | + | 100 | |
| 5811 | + | ], | |
| 5812 | + | "pause_at_limit": true, | |
| 5813 | + | "webhook": "https://ops.example.com/g1t/budget", | |
| 5814 | + | "state": "ok", | |
| 5815 | + | "message": null | |
| 5816 | + | }, | |
| 5817 | + | "notes": "All amounts are whole millionths of a dollar: 500000000 is $500. `max_amount_micros` is null for g1t's own workspaces, which have no ceiling. When `state` is `stopped`, new sandboxes, builds and app requests wait until the limit is raised or the month closes." | |
| 5818 | + | }, | |
| 5819 | + | "set_budget": { | |
| 5820 | + | "params": { | |
| 5821 | + | "workspace": "flagon-io" | |
| 5822 | + | }, | |
| 5823 | + | "request": { | |
| 5824 | + | "amount_micros": 500000000, | |
| 5825 | + | "alerts": [ | |
| 5826 | + | 50, | |
| 5827 | + | 75, | |
| 5828 | + | 90, | |
| 5829 | + | 100 | |
| 5830 | + | ], | |
| 5831 | + | "webhook": "https://ops.example.com/g1t/budget" | |
| 5832 | + | }, | |
| 5833 | + | "response": { | |
| 5834 | + | "workspace": "flagon-io", | |
| 5835 | + | "amount_micros": 500000000, | |
| 5836 | + | "automatic": false, | |
| 5837 | + | "spent_micros": 132450000, | |
| 5838 | + | "max_amount_micros": 2000000000, | |
| 5839 | + | "alerts": [ | |
| 5840 | + | 50, | |
| 5841 | + | 75, | |
| 5842 | + | 90, | |
| 5843 | + | 100 | |
| 5844 | + | ], | |
| 5845 | + | "pause_at_limit": true, | |
| 5846 | + | "webhook": "https://ops.example.com/g1t/budget", | |
| 5847 | + | "state": "ok", | |
| 5848 | + | "message": null | |
| 5849 | + | }, | |
| 5850 | + | "notes": "Fields left out keep their value. A limit above `max_amount_micros` is refused with `invalid`. Called by anyone but an owner signed in as a person, or by a workspace's own token or one of g1t's agents, it is refused with `forbidden`. Each alert is sent once a month, to the owners by email and, with `webhook`, as a JSON POST." | |
| 5851 | + | }, | |
| 5852 | + | "get_ai_credit": { | |
| 5853 | + | "params": { | |
| 5854 | + | "workspace": "flagon-io" | |
| 5855 | + | }, | |
| 5856 | + | "response": { | |
| 5857 | + | "balance_micros": 62000000, | |
| 5858 | + | "purchased_micros": 50000000, | |
| 5859 | + | "given_micros": 12000000, | |
| 5860 | + | "grants": [ | |
| 5861 | + | { | |
| 5862 | + | "id": "crd_01kkr2m4c8f1t7qh3d6n9w5p0x", | |
| 5863 | + | "workspace": "flagon-io", | |
| 5864 | + | "kind": "purchase", | |
| 5865 | + | "amount_micros": 50000000, | |
| 5866 | + | "used_micros": 0, | |
| 5867 | + | "left_micros": 50000000, | |
| 5868 | + | "note": "AI credit bought", | |
| 5869 | + | "refund_for": null, | |
| 5870 | + | "refund_day": null, | |
| 5871 | + | "expires_at": "2027-10-02T00:00:00.000Z", | |
| 5872 | + | "created_by": "ana", | |
| 5873 | + | "created_at": "2026-10-02T16:20:00.000Z", | |
| 5874 | + | "state": "open", | |
| 5875 | + | "closed_at": null, | |
| 5876 | + | "closed_note": null, | |
| 5877 | + | "closed_by": null | |
| 5878 | + | } | |
| 5879 | + | ], | |
| 5880 | + | "free_via_discount": false, | |
| 5881 | + | "postpaid": false, | |
| 5882 | + | "blocked": false, | |
| 5883 | + | "can_buy": true, | |
| 5884 | + | "presets_cents": [ | |
| 5885 | + | 2500, | |
| 5886 | + | 5000, | |
| 5887 | + | 10000, | |
| 5888 | + | 25000 | |
| 5889 | + | ], | |
| 5890 | + | "min_cents": 1000, | |
| 5891 | + | "max_cents": 100000, | |
| 5892 | + | "card_fee": { | |
| 5893 | + | "on": true, | |
| 5894 | + | "percent_micros": 29000, | |
| 5895 | + | "fixed_cents": 30 | |
| 5896 | + | }, | |
| 5897 | + | "reload": { | |
| 5898 | + | "enabled": false, | |
| 5899 | + | "threshold_micros": 10000000, | |
| 5900 | + | "target_micros": 50000000, | |
| 5901 | + | "monthly_max_micros": 200000000, | |
| 5902 | + | "reloaded_micros": 0, | |
| 5903 | + | "failed_at": null, | |
| 5904 | + | "error": null | |
| 5905 | + | }, | |
| 5906 | + | "agent_rate_micros": 3.6, | |
| 5907 | + | "model_markup_percent": 10, | |
| 5908 | + | "gateway_markup_percent": 5, | |
| 5909 | + | "upgrade_credit_micros": 10000000, | |
| 5910 | + | "expires_days": 365 | |
| 5911 | + | }, | |
| 5912 | + | "notes": "`card_fee.percent_micros` is per dollar charged, in millionths: 29000 is 2.9%. `blocked` is true when the credit has run out and new runs on g1t's models wait for more; runs on a model provider the workspace connected itself never need it." | |
| 5913 | + | }, | |
| 5914 | + | "buy_ai_credit": { | |
| 5915 | + | "params": { | |
| 5916 | + | "workspace": "flagon-io" | |
| 5917 | + | }, | |
| 5918 | + | "request": { | |
| 5919 | + | "amount_cents": 5000 | |
| 5920 | + | }, | |
| 5921 | + | "response": { | |
| 5922 | + | "url": "https://checkout.example.com/c/pay/cs_test_a1b2c3" | |
| 5923 | + | }, | |
| 5924 | + | "notes": "Open `url` in a browser to pay. Nothing is charged until the payment is made there; the credit then shows in get_ai_credit. An amount outside `min_cents` to `max_cents`, or not in whole dollars, is refused with `invalid`; a workspace that cannot buy credit (see `can_buy`) gets `conflict` or `payment_required`." | |
| 5925 | + | }, | |
| 5926 | + | "list_invoices": { | |
| 5927 | + | "params": { | |
| 5928 | + | "workspace": "flagon-io" | |
| 5929 | + | }, | |
| 5930 | + | "response": { | |
| 5931 | + | "invoices": [ | |
| 5932 | + | { | |
| 5933 | + | "id": "in_1Q2w3E4r5T6y7U8i", | |
| 5934 | + | "number": "FLAGON-0004", | |
| 5935 | + | "status": "paid", | |
| 5936 | + | "total_cents": 4210, | |
| 5937 | + | "currency": "usd", | |
| 5938 | + | "created_at": "2026-10-01T00:05:00.000Z", | |
| 5939 | + | "description": "Usage for 2026-09", | |
| 5940 | + | "hosted_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i", | |
| 5941 | + | "pdf_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i/pdf" | |
| 5942 | + | } | |
| 5943 | + | ], | |
| 5944 | + | "usage_invoices": [ | |
| 5945 | + | { | |
| 5946 | + | "id": "inv_01kkqz8d3c6f9t2wq5n8h1m4r7", | |
| 5947 | + | "workspace": "flagon-io", | |
| 5948 | + | "reason": "month", | |
| 5949 | + | "period": "2026-09", | |
| 5950 | + | "amount_micros": 42100000, | |
| 5951 | + | "status": "paid", | |
| 5952 | + | "hosted_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i", | |
| 5953 | + | "pdf_url": "https://invoice.example.com/i/acct_1/in_1Q2w3E4r5T6y7U8i/pdf", | |
| 5954 | + | "lines": [ | |
| 5955 | + | { | |
| 5956 | + | "description": "Agent: models", | |
| 5957 | + | "amount_micros": 35900000 | |
| 5958 | + | }, | |
| 5959 | + | { | |
| 5960 | + | "description": "Sandbox time", | |
| 5961 | + | "amount_micros": 6200000 | |
| 5962 | + | } | |
| 5963 | + | ], | |
| 5964 | + | "created_at": "2026-10-01T00:05:00.000Z" | |
| 5965 | + | } | |
| 5966 | + | ], | |
| 5967 | + | "upcoming": { | |
| 5968 | + | "closes_at": "2026-11-01T00:00:00.000Z", | |
| 5969 | + | "subscriptions_micros": 20000000, | |
| 5970 | + | "usage_micros": 10210000, | |
| 5971 | + | "total_micros": 30210000 | |
| 5972 | + | }, | |
| 5973 | + | "unavailable": null | |
| 5974 | + | }, | |
| 5975 | + | "notes": "`invoices` are in cents (`total_cents`); `usage_invoices` and `upcoming` in millionths of a dollar. A usage invoice's `reason` is `month` (the month closed) or `threshold` (charged near the limit), and its `status` `paid`, `open`, `failed` or `void`. An invoice's `status` is `paid`, `open`, `void`, `uncollectible` or `draft`." | |
| 5976 | + | }, | |
| 5977 | + | "get_billing_details": { | |
| 5978 | + | "params": { | |
| 5979 | + | "workspace": "flagon-io" | |
| 5980 | + | }, | |
| 5981 | + | "response": { | |
| 5982 | + | "customer": true, | |
| 5983 | + | "email": "billing@flagon.example.com", | |
| 5984 | + | "name": "Flagon, Inc.", | |
| 5985 | + | "address": { | |
| 5986 | + | "line1": "100 Market Street", | |
| 5987 | + | "line2": "", | |
| 5988 | + | "city": "San Francisco", | |
| 5989 | + | "state": "CA", | |
| 5990 | + | "postal_code": "94105", | |
| 5991 | + | "country": "US" | |
| 5992 | + | }, | |
| 5993 | + | "tax_id_type": "us_ein", | |
| 5994 | + | "tax_id": "12-3456789", | |
| 5995 | + | "po_number": null, | |
| 5996 | + | "language": "en", | |
| 5997 | + | "payment_method": { | |
| 5998 | + | "kind": "card", | |
| 5999 | + | "brand": "visa", | |
| 6000 | + | "last4": "4242", | |
| 6001 | + | "exp_month": 12, | |
| 6002 | + | "exp_year": 2028 | |
| 6003 | + | } | |
| 6004 | + | }, | |
| 6005 | + | "notes": "Owners change these on the workspace's billing page. `payment_method` is null until a card or other way to pay is saved." | |
| 6006 | + | }, | |
| 5637 | 6007 | "list_pinned_projects": { | |
| 5638 | 6008 | "params": { | |
| 5639 | 6009 | "workspace": "flagon-io" |
| 120 | 120 | &[], | |
| 121 | 121 | ), | |
| 122 | 122 | route("GET", "/workspaces/:workspace/members/:username/teams", Op::ListUserTeams, &[]), | |
| 123 | + | // A workspace's billing: usage, budget, AI credit and invoices. | |
| 124 | + | route( | |
| 125 | + | "GET", | |
| 126 | + | "/workspaces/:workspace/usage", | |
| 127 | + | Op::GetUsage, | |
| 128 | + | &[("from", "from"), ("until", "until"), ("products", "products"), ("projects", "projects"), ("group_by", "group_by")], | |
| 129 | + | ), | |
| 130 | + | route("GET", "/workspaces/:workspace/budget", Op::GetBudget, &[]), | |
| 131 | + | route("PUT", "/workspaces/:workspace/budget", Op::SetBudget, &[]), | |
| 132 | + | route("GET", "/workspaces/:workspace/ai_credit", Op::GetAiCredit, &[]), | |
| 133 | + | route("POST", "/workspaces/:workspace/ai_credit/checkout", Op::BuyAiCredit, &[]), | |
| 134 | + | route("GET", "/workspaces/:workspace/invoices", Op::ListInvoices, &[]), | |
| 135 | + | route("GET", "/workspaces/:workspace/billing_details", Op::GetBillingDetails, &[]), | |
| 123 | 136 | // Code owners: the CODEOWNERS file, checked. | |
| 124 | 137 | route("GET", "/repos/:owner/:name/codeowners/errors", Op::GetCodeownersErrors, &[("ref", "ref")]), | |
| 125 | 138 | // Security alerts: secrets and vulnerable dependencies. | |
| ⋯ | |||
| 1003 | 1016 | } | |
| 1004 | 1017 | ||
| 1005 | 1018 | #[test] | |
| 1019 | + | fn billing_is_addressed_by_workspace() { | |
| 1020 | + | let query = [("from".to_owned(), "2026-10-01".to_owned()), ("products".to_owned(), "agent,sandboxes".to_owned())]; | |
| 1021 | + | let (route, input) = resolve("GET", "/workspaces/acme/usage", &query, Value::Null).unwrap(); | |
| 1022 | + | assert_eq!(route.op, Op::GetUsage); | |
| 1023 | + | assert_eq!(input, json!({ "from": "2026-10-01", "products": "agent,sandboxes", "workspace": "acme" })); | |
| 1024 | + | let (route, input) = resolve("PUT", "/workspaces/acme/budget", &[], json!({ "alerts": [50] })).unwrap(); | |
| 1025 | + | assert_eq!(route.op, Op::SetBudget); | |
| 1026 | + | assert_eq!(input, json!({ "alerts": [50], "workspace": "acme" })); | |
| 1027 | + | let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op; | |
| 1028 | + | assert_eq!(op("GET", "/workspaces/acme/budget"), Op::GetBudget); | |
| 1029 | + | assert_eq!(op("GET", "/workspaces/acme/ai_credit"), Op::GetAiCredit); | |
| 1030 | + | assert_eq!(op("POST", "/workspaces/acme/ai_credit/checkout"), Op::BuyAiCredit); | |
| 1031 | + | assert_eq!(op("GET", "/workspaces/acme/invoices"), Op::ListInvoices); | |
| 1032 | + | assert_eq!(op("GET", "/workspaces/acme/billing_details"), Op::GetBillingDetails); | |
| 1033 | + | } | |
| 1034 | + | ||
| 1035 | + | #[test] | |
| 1006 | 1036 | fn query_parameters_are_renamed() { | |
| 1007 | 1037 | let query = [ | |
| 1008 | 1038 | ("q".to_owned(), "parser".to_owned()), | |
| 289 | 289 | ], | |
| 290 | 290 | }, | |
| 291 | 291 | Tool { | |
| 292 | + | name: "billing", | |
| 293 | + | title: "Billing", | |
| 294 | + | description: "A workspace's billing: its usage by product, project and day, its budget (the monthly spend limit, alerts and whether usage pauses at it), its AI credit, and its invoices. Amounts are whole millionths of a dollar (`_micros`), or cents (`_cents`) where named. Members read it; changing the budget and buying credit are for owners, as people, and never for g1t's agents.", | |
| 295 | + | default_action: Some("usage"), | |
| 296 | + | actions: &[ | |
| 297 | + | a("usage", Op::GetUsage, "Usage over a range of days, by product, meter, project and day, and what paid for it"), | |
| 298 | + | a("budget", Op::GetBudget, "The monthly spend limit, what was spent, alerts and whether usage pauses at the limit"), | |
| 299 | + | a("set_budget", Op::SetBudget, "Change the spend limit, alerts, pausing or the alert webhook"), | |
| 300 | + | a("ai_credit", Op::GetAiCredit, "AI credit left, its grants, auto-reload and how to buy more"), | |
| 301 | + | a("buy_ai_credit", Op::BuyAiCredit, "A payment page to buy AI credit, for a person to open"), | |
| 302 | + | a("invoices", Op::ListInvoices, "Every invoice, the itemised usage invoices, and the next one so far"), | |
| 303 | + | a("billing_details", Op::GetBillingDetails, "Who invoices are made out to and the payment method on file"), | |
| 304 | + | ], | |
| 305 | + | }, | |
| 306 | + | Tool { | |
| 292 | 307 | name: "security", | |
| 293 | 308 | title: "Security", | |
| 294 | 309 | description: "A repository's security: secret scanning alerts and push protection bypasses, custom secret patterns, code scanning alerts and SARIF uploads, vulnerability alerts, the dependency graph and its SBOM, dependency review, settings, and a workspace's overview. Fix an alert with g1t. Findings are shown to those who can change the code only. Give `repo` (owner/name), or `workspace` for lists across one.", | |
| ⋯ | |||
| 646 | 661 | assert!(tool.action(default).is_some(), "{}", tool.name); | |
| 647 | 662 | } | |
| 648 | 663 | } | |
| 649 | − | assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len()); | |
| 664 | + | assert!(TOOLS.len() <= 17, "{} tools", TOOLS.len()); | |
| 650 | 665 | } | |
| 651 | 666 | ||
| 652 | 667 | #[test] | |
| 326 | 326 | | Notifications | `notifications:read`, `notifications:write` | | |
| 327 | 327 | | Security | `security:read`, `security:write` | | |
| 328 | 328 | | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` | | |
| 329 | + | | Billing | `billing:read`, `billing:write` | | |
| 329 | 330 | | Runners | `runners:read` | | |
| 330 | 331 | | Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` | | |
| 331 | 332 | ||
| ⋯ | |||
| 360 | 361 | | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories | | |
| 361 | 362 | | `workspace:read` | Read workspace invites, integrations, model routes and [teams](/guides/teams/) | | |
| 362 | 363 | | `workspace:admin` | Create and delete workspaces, invite members, manage teams, connect integrations | | |
| 364 | + | | `billing:read` | See a workspace's [usage, budget, AI credit and invoices](/guides/usage-and-billing/) | | |
| 365 | + | | `billing:write` | Change a workspace's budget and buy AI credit. Only owners, as people: a workspace's own token and g1t's agents never change billing, whatever their scopes. Not in any preset but full access. | | |
| 363 | 366 | | `access:read` | See who has access to repositories | | |
| 364 | 367 | | `access:admin` | Give people and teams access to repositories, and take it away | | |
| 365 | 368 | | `webhooks:read` | See webhooks and their deliveries | | |
| 32 | 32 | repository is [transferred](/guides/transferring-repositories/#deployments) | |
| 33 | 33 | to another workspace or [renamed](/guides/managing-repositories/), its apps | |
| 34 | 34 | are built again under addresses with the new name, and the old addresses | |
| 35 | − | redirect to them for 90 days. | |
| 35 | + | redirect to them for 90 days. A rebuild that fails is tried again an hour | |
| 36 | + | later, then two hours after that. After three failures with the same | |
| 37 | + | error, or after one whose commit no longer exists, it is not tried again | |
| 38 | + | until you push or choose **Redeploy**. | |
| 36 | 39 | ||
| 37 | 40 | When the [default branch changes](/guides/managing-repositories/), production | |
| 38 | 41 | is built again from the new default branch. An | |
| ⋯ | |||
| 164 | 167 | A newer push replaces a build that is still running for the same pull | |
| 165 | 168 | request. Previews are marked `noindex`, so search engines leave them alone. | |
| 166 | 169 | ||
| 170 | + | The **Deployments** page lists the same failure of one app, repeated, as a | |
| 171 | + | single row with how many times it happened and when it last did. The row | |
| 172 | + | opens the newest of them. | |
| 173 | + | ||
| 167 | 174 | ## Production | |
| 168 | 175 | ||
| 169 | 176 | Each push to the default branch, which is each merge on a protected | |
| ⋯ | |||
| 381 | 388 | ||
| 382 | 389 | ### Seeing what you use | |
| 383 | 390 | ||
| 384 | − | - **Billing**, under the plan, shows **This month's usage**: *Builds* | |
| 391 | + | - **Usage** shows **Deployments** with *Builds* | |
| 385 | 392 | (build minutes and their cost), *Requests & CPU* and *Custom domains*, | |
| 386 | 393 | in dollars, beside the rest of the workspace's usage. Requests and CPU | |
| 387 | 394 | time are counted from Cloudflare's analytics every 10 minutes. | |
| ⋯ | |||
| 448 | 455 | | "Custom domains are being switched on" | Custom domains are not on for g1t.page yet. Domains you add are kept, and set up by themselves once they are. | | |
| 449 | 456 | | A domain stays at **Waiting for DNS** | Check the record against the one listed, remove other `A`/`AAAA` records for the same name, then choose **Check now**. | | |
| 450 | 457 | | A domain says "This domain is not set up" | It points at g1t, but no project has added it. Add it under **Settings → Domains**. | | |
| 458 | + | | "This pull request's commit no longer exists" | The pull request's head was force-pushed over, or its branch deleted, after the build was asked for. Push to the pull request again, or close it. g1t does not try that commit again. | | |
| 451 | 459 | | "The build did not finish in 45 minutes" | The build stopped reporting: a build stops at 30 minutes, and one not heard from after 45 is failed. Make the build faster, or build less for previews with **Build command**. | | |
| 452 | 460 | ||
| 453 | 461 | ## Running your own g1t | |
| 121 | 121 | every branch: the default one first, then those with a commit in the last 90 | |
| 122 | 122 | days (**Active**), then the rest (**Stale**). Each shows its last commit, how | |
| 123 | 123 | many commits it is ahead of and behind the default branch, the pull request | |
| 124 | − | open on it with its checks, and its preview when it has one. A count with a | |
| 125 | − | `+` ran past how far back g1t reads, 40 commits on the branch and 120 on the | |
| 126 | − | default. Search narrows the list by name. | |
| 124 | + | open on it with its checks, and its preview when it has one. A branch with | |
| 125 | + | no pull request links to opening one; one with nothing the default branch | |
| 126 | + | lacks (ahead `0`) says **Nothing to merge** instead. The counts are exact, | |
| 127 | + | merges included. g1t reads up to 1,000 commits of each history to find | |
| 128 | + | where the two meet; a branch that left the default branch further back | |
| 129 | + | than that shows no counts. Search narrows the list by name. | |
| 127 | 130 | ||
| 128 | 131 | The **Tags** tab lists tags newest first, up to 100, each with its commit | |
| 129 | 132 | and a ZIP of its files. |
| 143 | 143 | first. Each shows its last commit and who made it, how many commits it is | |
| 144 | 144 | ahead of the default branch and behind it, and its open pull request, | |
| 145 | 145 | with its checks, and preview, if it has them. A branch with no pull | |
| 146 | − | request links to opening one. | |
| 146 | + | request links to opening one, unless it has nothing the default branch | |
| 147 | + | lacks, which says **Nothing to merge**. | |
| 147 | 148 | ||
| 148 | − | Ahead and behind are counted from the last 40 commits of the branch and | |
| 149 | − | the last 120 of the default branch. A count that runs past that shows as | |
| 150 | − | `40+`. Ten branches are read, those with open pull requests first; a | |
| 151 | − | project with more says how many it has. | |
| 149 | + | Ahead and behind are exact, merges included. g1t reads up to 1,000 | |
| 150 | + | commits of each history to find where the two meet; a branch that left | |
| 151 | + | the default branch further back than that shows no counts. Ten branches | |
| 152 | + | are read, those with open pull requests first; a project with more says | |
| 153 | + | how many it has. | |
| 152 | 154 | ||
| 153 | 155 | ## Settings | |
| 154 | 156 |
| 74 | 74 | ||
| 75 | 75 | ### What it costs | |
| 76 | 76 | ||
| 77 | − | Every price is what g1t pays plus 20%. The live figures are on | |
| 78 | − | [g1t.sh/pricing](https://g1t.sh/pricing). | |
| 77 | + | Every price is what g1t pays plus 20%, except models: they are charged at | |
| 78 | + | the provider's price with no markup, and g1t's own part is the agent rate. | |
| 79 | + | The live figures are on [g1t.sh/pricing](https://g1t.sh/pricing). | |
| 79 | 80 | ||
| 80 | 81 | | What | Unit | Costs g1t | You pay | | |
| 81 | 82 | | --- | --- | --- | --- | | |
| 82 | − | | Models | A run | What the provider charged | Cost + 20% | | |
| 83 | + | | Agent models | A run | What the provider charged | The provider's price, from [AI credit](#ai-credit) | | |
| 84 | + | | g1t agent rate | Million tokens a run uses (input, output and cached) | — | $0.25, from Oct 22, 2026 | | |
| 85 | + | | AI Gateway | A request | What the provider charged | The provider's price: free of markup during beta | | |
| 83 | 86 | | Sandbox time (agents, workflows, the merge queue) | Second | About $0.001 a minute | About $0.0012 a minute | | |
| 84 | 87 | | [Larger machines](#workflow-jobs-on-larger-machines) for workflow jobs (`g1t-2core`, `g1t-4core`) | Second | About 2.8 and 5.1 times a sandbox second | Cost + 20% | | |
| 85 | 88 | | Deploy builds | Second | About $0.001 a minute | About $0.0012 a minute | | |
| ⋯ | |||
| 113 | 116 | 3. If the workspace already has a checked card, the plan starts on it at | |
| 114 | 117 | once. Otherwise, pay on the card page you are sent to. | |
| 115 | 118 | ||
| 116 | − | Back on Billing, the card says **On the g1t plan** (**first month** in | |
| 117 | − | the first billing cycle), with a meter for the month's included usage and | |
| 118 | − | **This month's usage**: what each kind of usage has come to so far, in | |
| 119 | − | dollars and in what was used (*Agents & sandboxes*, *Builds*, *Requests & | |
| 120 | − | CPU*, *Custom domains*, *Git operations & storage*, *Search & security | |
| 121 | − | scans*). Its **Total at price** is g1t's usage at cost plus 20%, before the | |
| 122 | − | included usage, the trial, a pool or a free period paid their part, so it | |
| 123 | − | can be more than what was charged. Runs on your own model provider are not | |
| 124 | − | in it: your provider bills those. The workspace's **Usage** page shows what | |
| 125 | − | was charged as **Spent**, and how much of the total was not charged. App traffic, custom domains, storage and git | |
| 126 | − | operations are counted through the month and charged when it closes. **Manage on Stripe** opens the card, invoices and | |
| 127 | − | billing details. **End at the end of the period** ends the plan then, with | |
| 128 | − | nothing more charged after; **Keep the plan** takes that back until then. | |
| 129 | − | If a renewal payment fails, the card says **Payment failed**, with | |
| 130 | − | **Update payment on Stripe**, and the plan's features stop until it is | |
| 131 | − | paid. | |
| 119 | + | Back on Billing, the plan's card says **On the g1t plan** (**first month** | |
| 120 | + | in the first billing cycle), with the billing period, a meter for the | |
| 121 | + | month's included usage, and the **Upcoming invoice**: the plan and add-ons | |
| 122 | + | at their monthly price plus usage still owed after included usage, credit | |
| 123 | + | and any discount, from g1t's own ledger (**View upcoming invoice** splits | |
| 124 | + | it). **Usage** and **Invoices** go to each. Runs on your own model provider | |
| 125 | + | are not in it: your provider bills those. App traffic, custom domains, | |
| 126 | + | storage and git operations are counted through the month and charged when | |
| 127 | + | it closes. **Downgrade to free at the period's end** ends the plan then, | |
| 128 | + | with nothing more charged after; **Keep the plan** takes that back until | |
| 129 | + | then. If a renewal payment fails, the card says **Payment failed**, and the | |
| 130 | + | plan's features stop until it is paid: update the card with **Manage in | |
| 131 | + | Stripe** under **Payment method**. | |
| 132 | + | ||
| 133 | + | Starting the plan uses the card from the card check, or else the default | |
| 134 | + | card on Stripe's billing page, without a second page; with neither, Stripe's | |
| 135 | + | page asks for one. If Stripe refuses, the page says why in a sentence. | |
| 132 | 136 | ||
| 133 | 137 | A card that says **100% discount from g1t** or **Included by g1t** is on | |
| 134 | 138 | under terms g1t set with the workspace, such as a | |
| ⋯ | |||
| 220 | 224 | | Repositories, issues, pull requests, review, search, the API and MCP | No | | |
| 221 | 225 | ||
| 222 | 226 | Each run is charged when it finishes: what the model provider charged for | |
| 223 | − | it, plus 20%, and its sandbox time. A small change costs a few cents. | |
| 227 | + | it, with no markup; the agent rate on the tokens it used, on a line of its | |
| 228 | + | own (*g1t agent rate: 1,240,000 tokens for work on acme/api#12*); and its | |
| 229 | + | sandbox time. A small change costs a few cents. Tokens counted after a run | |
| 230 | + | reports are charged when it is settled. Until Oct 22, 2026 the agent rate is | |
| 231 | + | $0, and from Oct 8, 2026 models carry no markup (before, cost plus 20%); both | |
| 232 | + | are dated changes on the pricing page. | |
| 224 | 233 | ||
| 225 | 234 | Work a workspace routes to [its own model providers](/guides/models/) is | |
| 226 | 235 | paid for at those providers instead. Such a run is charged here only for | |
| ⋯ | |||
| 230 | 239 | assigned the issue. That is why putting g1t to work on a | |
| 231 | 240 | repository needs the Write [role](/guides/access-and-roles/) or higher on it. | |
| 232 | 241 | ||
| 242 | + | ## Add-ons | |
| 243 | + | ||
| 244 | + | An add-on is a monthly price per workspace that turns on more of g1t. Each | |
| 245 | + | is its own line on the workspace's Stripe subscription and is turned on or | |
| 246 | + | off from **Billing → Add-ons**. | |
| 247 | + | ||
| 248 | + | | Add-on | Price | What it adds | | |
| 249 | + | | --- | --- | --- | | |
| 250 | + | | Security and quality | $10 a month | Custom secret patterns, validity checks, delegated bypass, code scanning, dependency review and the security overview on **private** repositories. Public repositories get all of it free. | | |
| 251 | + | ||
| 252 | + | Secret scanning, push protection, vulnerability alerts, security updates, | |
| 253 | + | the dependency graph and SBOMs are free everywhere, without the add-on. An | |
| 254 | + | add-on does not need the plan, and the plan does not include one. Agent work | |
| 255 | + | it starts, such as **Fix with g1t**, is ordinary usage. See | |
| 256 | + | [What's free and what's paid](/guides/security/pricing/). | |
| 257 | + | ||
| 233 | 258 | ## How prices are set | |
| 234 | 259 | ||
| 235 | 260 | Every price is what g1t pays for the thing, at Cloudflare or a model | |
| ⋯ | |||
| 460 | 485 | and by email, and the section shows the request and its answer. An approved amount becomes a | |
| 461 | 486 | floor under your ceiling, and your spend limit can go up to it. | |
| 462 | 487 | ||
| 488 | + | ### Budget alerts | |
| 489 | + | ||
| 490 | + | The spend limit is the workspace's monthly **budget** on usage past what the | |
| 491 | + | plan includes. Under **Budget alerts**, owners choose: | |
| 492 | + | ||
| 493 | + | | Setting | Means | | |
| 494 | + | | --- | --- | | |
| 495 | + | | **Alert at** | Any of 50, 75, 90 and 100% of the spend limit. Each is emailed to the owners once a month. | | |
| 496 | + | | **Pause usage at 100%** | On (the default), new work stops at the limit. Off, the budget only alerts; g1t's own ceiling still applies. | | |
| 497 | + | | **Webhook** | An `https://` address of your own, sent a JSON `POST` at each alert: `event` (`budget.alert`), `workspace`, `level_percent`, `spent_micros`, `budget_micros`, `sent_at`. | | |
| 498 | + | ||
| 499 | + | Choose **Save alerts**. Through the API: `PUT /workspaces/:workspace/budget`, | |
| 500 | + | or the MCP `billing` tool's `set_budget` action. | |
| 501 | + | ||
| 463 | 502 | ### Prepay | |
| 464 | 503 | ||
| 465 | 504 | Paying in advance pays for usage as it happens, after the plan's included | |
| ⋯ | |||
| 529 | 568 | person. The credit appears on the statement with the day it happened, | |
| 530 | 569 | such as *Credit from g1t: accidental usage on 2026-11-12*. | |
| 531 | 570 | ||
| 571 | + | ## AI credit | |
| 572 | + | ||
| 573 | + | Agent and AI Gateway usage is prepaid: a workspace on the plan buys **AI | |
| 574 | + | credit**, and model usage draws on it, so g1t never fronts a model's cost. | |
| 575 | + | The plan's included usage pays first; AI credit pays next, before any other | |
| 576 | + | credit. Starting the plan comes with **$5 of AI credit, once** (it expires | |
| 577 | + | a year after it is given). | |
| 578 | + | ||
| 579 | + | ### Buy AI credit | |
| 580 | + | ||
| 581 | + | Only an owner can buy it. | |
| 582 | + | ||
| 583 | + | 1. Open **Settings → Billing and plans**, and find **AI credit**. | |
| 584 | + | 2. Choose $10, $25, $50 or $100, or **Custom** and type a whole-dollar | |
| 585 | + | amount from $10 to $1,000. | |
| 586 | + | 3. Choose **Buy AI credit**, and pay on Stripe's page. | |
| 587 | + | ||
| 588 | + | Stripe's card fee (2.9% + $0.30) is its own line on that page, **Card | |
| 589 | + | processing fee**, so the credit you get is the amount you chose. Invoiced | |
| 590 | + | billing never carries it. The credit is added once Stripe says the payment | |
| 591 | + | was made, whether or not you come back to g1t, and **expires 1 year after | |
| 592 | + | purchase**. The card is kept for auto-reload. | |
| 593 | + | ||
| 594 | + | ### Auto-reload | |
| 595 | + | ||
| 596 | + | Off by default. When it is on and AI credit falls below the amount you set, | |
| 597 | + | g1t charges the saved card to bring it back to your target, in whole | |
| 598 | + | dollars and at least $10, never more than your monthly maximum in a | |
| 599 | + | calendar month (UTC). | |
| 600 | + | ||
| 601 | + | | Setting | Means | | |
| 602 | + | | --- | --- | | |
| 603 | + | | **When AI credit falls below** | The threshold, such as $10 | | |
| 604 | + | | **Reload it to** | The target, at least $10 above the threshold, at most $1,000 | | |
| 605 | + | | **At most … a month** | The most auto-reload charges in a month, up to $10,000 | | |
| 606 | + | ||
| 607 | + | g1t checks every 15 minutes, and right away when a run would otherwise | |
| 608 | + | wait. If the card cannot be charged, auto-reload turns itself off and the | |
| 609 | + | owners are emailed. Turn it on again after updating the card on Stripe. | |
| 610 | + | ||
| 611 | + | ### At $0 | |
| 612 | + | ||
| 613 | + | With no AI credit left and this month's included usage used, new runs on | |
| 614 | + | g1t's models do not start, and the reason says to buy credit or turn on | |
| 615 | + | auto-reload. Runs already going finish. Runs on your | |
| 616 | + | [own model provider](/guides/models/) are not affected. A workspace with a | |
| 617 | + | 100% discount gets AI usage free through the discount, shown at its price | |
| 618 | + | and then the discount; an enterprise is invoiced for it after use. | |
| 619 | + | ||
| 532 | 620 | ## Credits from g1t | |
| 533 | 621 | ||
| 534 | 622 | g1t sometimes adds credit to a workspace: a welcome or referral credit, an | |
| ⋯ | |||
| 592 | 680 | ||
| 593 | 681 | ## Your card and billing details | |
| 594 | 682 | ||
| 595 | − | These live on **Stripe's billing page**, not on g1t: g1t never sees or | |
| 596 | − | stores card numbers. An owner opens it with **Open Stripe billing** under | |
| 597 | − | **Card and invoices** on **Settings → Billing and plans**, and there adds or replaces the card, downloads invoices and | |
| 598 | − | receipts, and sets the billing email, address and tax ID. g1t support | |
| 599 | − | never takes card details by phone or email. | |
| 683 | + | Cards live on **Stripe's billing page**, not on g1t: g1t never sees or | |
| 684 | + | stores card numbers. On **Settings → Billing and plans**: | |
| 600 | 685 | ||
| 686 | + | - **Payment method** shows the default card (brand, last four digits and | |
| 687 | + | expiry). **Manage in Stripe** opens Stripe's page, where an owner adds, | |
| 688 | + | removes or replaces a card and makes one the default. The plan and | |
| 689 | + | auto-reload charge the default card. | |
| 690 | + | - **Invoice details** are kept on the workspace's Stripe customer and | |
| 691 | + | printed on every invoice: the invoice email, company name, billing | |
| 692 | + | address, tax ID (its kind and number), a purchase order, and the invoice | |
| 693 | + | language. An owner edits them here and chooses **Save invoice details**. | |
| 694 | + | - **Invoices** lists every invoice Stripe sent (the plan, add-ons, AI | |
| 695 | + | credit and month-end usage), each with **View** and **PDF**. | |
| 696 | + | - **Add-ons** lists what can be turned on beside the plan, such as the | |
| 697 | + | [Security and quality activation](/guides/security/), with its price and | |
| 698 | + | **Turn on** or **Turn off**. | |
| 699 | + | ||
| 700 | + | g1t support never takes card details by phone or email. | |
| 701 | + | ||
| 601 | 702 | If a card is declined, work stops until the workspace pays, and the | |
| 602 | 703 | Billing page and the API say why. Replace the card or prepay to clear it. | |
| 603 | 704 | A payment disputed with the card's bank stops work the same way. | |
| 604 | 705 | ||
| 605 | − | While payments on g1t are in test mode, no real card is charged. Use the | |
| 606 | − | test card `4242 4242 4242 4242` with any future date and any code. The | |
| 607 | − | Billing page says when payments are in test mode. | |
| 706 | + | Through the API, `GET /workspaces/:workspace/billing_details` and | |
| 707 | + | `GET /workspaces/:workspace/invoices` (the MCP `billing` tool's | |
| 708 | + | `billing_details` and `invoices` actions) read the same. | |
| 608 | 709 | ||
| 609 | 710 | ## When work is stopped | |
| 610 | 711 | ||
| ⋯ | |||
| 676 | 777 | ||
| 677 | 778 | ## The Usage page | |
| 678 | 779 | ||
| 679 | − | A workspace's **Usage** page, `g1t.sh/<workspace>/-/usage`, shows what its | |
| 680 | − | agents have cost. Every member can see it, from **Usage** in the | |
| 681 | − | workspace's sidebar. The workspace's overview, `g1t.sh/<workspace>`, has a | |
| 682 | − | **Usage** card with this month's spend: the plan's included usage or the | |
| 683 | − | trial credit used so far, on-demand charges past what is included, what it | |
| 684 | − | went on, and a way to **Billing**. | |
| 780 | + | A workspace's **Usage** page, `g1t.sh/<workspace>/-/usage`, shows what it | |
| 781 | + | used, by product, project and day. Every member can see it, from **Usage** | |
| 782 | + | in the workspace's sidebar. A project's own, `g1t.sh/<workspace>/<project>/usage`, | |
| 783 | + | shows the same for that project. | |
| 685 | 784 | ||
| 686 | − | These figures are what the agent harness reports for each run. Your model | |
| 687 | − | provider's own figures can differ by a few percent, because each prices | |
| 688 | − | the same tokens itself; the provider's invoice is what counts. | |
| 785 | + | Every amount is **usage at price**: what was charged, plus what included | |
| 786 | + | usage, credit or a discount paid for it. It is the one figure mission | |
| 787 | + | control, the agent fleet, Usage and Billing all show, so they agree. | |
| 689 | 788 | ||
| 690 | − | Pick a period: **This month**, **Last 7 days**, **Last 30 days** or **Last | |
| 691 | − | 90 days**. The page then shows: | |
| 789 | + | The filters, in one row: | |
| 692 | 790 | ||
| 693 | − | | | | | |
| 791 | + | | Filter | Choices | | |
| 694 | 792 | | --- | --- | | |
| 695 | − | | Spent | What the period cost, and how much of it was the model provider's. | | |
| 696 | − | | Agent runs | How many runs there were. | | |
| 697 | − | | Average run | What a run cost on average. | | |
| 698 | − | | Credit left | What is prepaid and not used yet, and about how many days it lasts at the period's rate. | | |
| 699 | − | | Spend per day | A chart of each day, split by kind of work. | | |
| 700 | − | | By kind of work | Making changes, reviews, catching up and planning. A revision counts as making a change. | | |
| 701 | − | | By repository | Each repository's share. | | |
| 702 | − | | Pull requests that cost most | The ten that cost most, each linked. Planning appears as the repository, linked to its plans. | | |
| 703 | − | | By model | Each model's share. | | |
| 793 | + | | Period | **Current billing cycle** (the calendar month, UTC), **Last billing cycle**, the last 7, 30 or 90 days, or a **Custom range** of up to 400 days. The days it covers are shown beside it. | | |
| 794 | + | | Products | Any of Agent, Sandboxes, AI Gateway, Deployments, Git & storage, Packages, Security & quality and Search. | | |
| 795 | + | | Projects | Any of the projects with usage in the period. | | |
| 796 | + | | Group by | Product, project or day, for the breakdown. | | |
| 797 | + | | **⋯** | **Export CSV** (a row per day, product and meter) and the usage API. | | |
| 798 | + | ||
| 799 | + | Then: | |
| 800 | + | ||
| 801 | + | - **Included usage, credit and this range**: the plan's included usage this | |
| 802 | + | month, AI credit and credit from g1t left, and what the range came to: | |
| 803 | + | usage at price, then the discount (shown as *Discount (100%)* for a | |
| 804 | + | workspace g1t covers in full), included usage and pools, credits applied, | |
| 805 | + | and what is charged. | |
| 806 | + | - **Consumption**: a column per day, week or month (**Daily**, **Weekly**, | |
| 807 | + | **Monthly**), stacked by product, with **Cumulative** to add them up. | |
| 808 | + | Hover or focus a column for each product's part; **Show as a table** has | |
| 809 | + | every number. | |
| 810 | + | - **The breakdown**: each product family with its meters (the agent's | |
| 811 | + | model tokens, agent rate and sandbox time; sandbox time; builds; git | |
| 812 | + | operations and private storage with what is free; and so on), each with a | |
| 813 | + | trend line, how much was used and its charge at price. Open a meter for | |
| 814 | + | its projects. The agent also shows its runs, reviews, plans and checks. | |
| 704 | 815 | ||
| 816 | + | Storage, git operations, scans and search embeddings are metered through | |
| 817 | + | the month and charged when it closes; until then they are marked pending. | |
| 818 | + | Through the API: `GET /workspaces/:workspace/usage`, or the MCP `billing` | |
| 819 | + | tool's `usage` action. | |
| 820 | + | ||
| 705 | 821 | ## The statement | |
| 706 | 822 | ||
| 707 | 823 | The **Billing** page ends with the workspace's statement, a month at a | |
| ⋯ | |||
| 784 | 900 | month's usage in six lines, `agents`, `builds`, `requests`, `domains`, | |
| 785 | 901 | `git_storage` and `search_scans`, each with `micros` (at cost plus 20%, | |
| 786 | 902 | before included usage or a pool paid for it) and a `quantity` such as | |
| 787 | − | *42 build minutes*. It is what **This month's usage** on Billing shows. | |
| 903 | + | *42 build minutes*, each at price (what was charged plus what paid for it), as Usage measures it. | |
| 904 | + | ||
| 905 | + | **`usage_report`** (`workspace`, `viewer`, `from`, `until`, optional `products` and `projects`; members only) is what the Usage page reads: totals (`priceMicros`, `discountMicros`, `includedMicros`, `creditsMicros`, `chargedMicros`, `pendingMicros`), each day's usage by product, and every product family with its meters, their daily figures and their projects. | |
| 788 | 906 | ||
| 789 | 907 | **`reserve`** holds the work's estimated cost before it starts, so starts | |
| 790 | 908 | at the same moment cannot overshoot together. `kind` is `agent`, `check`, | |
| 345 | 345 | ## The sidebar | |
| 346 | 346 | ||
| 347 | 347 | The sidebar is always about one workspace: the one the switcher at its top | |
| 348 | − | names. Choose the workspace's name to open its page, or the arrows beside | |
| 349 | − | it to switch, or for **Workspace overview** and **All projects**. | |
| 348 | + | names. On a workspace's pages, and on a project in one of your workspaces, | |
| 349 | + | that is the workspace the page belongs to; on a project somewhere you are | |
| 350 | + | not a member, it stays the one you chose last. Choose the workspace's name | |
| 351 | + | to open its page, or the arrows beside it to switch, or for **Workspace | |
| 352 | + | overview** and **All projects**. | |
| 350 | 353 | [Explore](https://g1t.sh/explore), public projects from all of g1t, is in | |
| 351 | 354 | the top bar, beside **Docs**. | |
| 352 | 355 |
| 197 | 197 | | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. | | |
| 198 | 198 | | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. | | |
| 199 | 199 | | [Notifications](/reference/api/notifications/list-notifications/) | Your inbox: its threads, why you were told of each, marking them read, done, saved or snoozed, and what you subscribe to and watch. See [your inbox](/guides/inbox/). | | |
| 200 | + | | [Billing](/reference/api/billing/get-usage/) | A workspace's usage by product, project and day, its budget, its AI credit and its invoices. See [usage and billing](/guides/usage-and-billing/). | | |
| 200 | 201 | | [Invites](/reference/api/invites/list-invites/) | Your invites while g1t is invite-only, and inviting people into a workspace by email. | | |
| 201 | 202 | | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. | | |
| 202 | 203 | | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. | |
| 3 | 3 | description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | The MCP server at `https://mcp.g1t.sh` exposes 16 tools, one per kind of | |
| 6 | + | The MCP server at `https://mcp.g1t.sh` exposes 17 tools, one per kind of | |
| 7 | 7 | thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`, | |
| 8 | 8 | `plan`, `memory`, `workflow`, `secret`, `security`, `webhook`, `access`, | |
| 9 | − | `team`, `workspace`, `notifications` and `account`. Each tool takes an `action` that says what to do. Every | |
| 9 | + | `team`, `workspace`, `billing`, `notifications` and `account`. Each tool takes an `action` that says what to do. Every | |
| 10 | 10 | action is the same operation as a route of the [REST API](/reference/api/), | |
| 11 | 11 | with the same inputs, permissions and results, so the two always agree. | |
| 12 | 12 | ||
| ⋯ | |||
| 549 | 549 | | [`unpin_project`](/reference/api/pinned-projects/unpin-project/) | Unpin it. Returns your pins. | `workspace`, `project` | `account:write` | | |
| 550 | 550 | | [`reorder_pinned_projects`](/reference/api/pinned-projects/reorder-pinned-projects/) | Put your pins in a new order: `projects` names each pinned project's slug once. | `workspace`, `projects` | `account:write` | | |
| 551 | 551 | ||
| 552 | + | ## `billing` | |
| 553 | + | ||
| 554 | + | A workspace's billing: its usage, its budget, its AI credit and its | |
| 555 | + | invoices. `usage` is the default action. Amounts are whole millionths of a | |
| 556 | + | dollar (`_micros`), or cents where a field says `_cents`. Members of the | |
| 557 | + | workspace read it, a workspace's own token included. Changing the budget | |
| 558 | + | and buying AI credit are for its owners, as people: signed in or with a | |
| 559 | + | personal access token. A workspace's token and g1t's agents never change | |
| 560 | + | billing, whatever their scopes, and no preset but full access includes | |
| 561 | + | `billing:write`. See [usage and billing](/guides/usage-and-billing/). | |
| 562 | + | ||
| 563 | + | | Action | What it does | Required | Scope | | |
| 564 | + | | --- | --- | --- | --- | | |
| 565 | + | | [`usage`](/reference/api/billing/get-usage/) | Usage over `from` to `until` (UTC days, `until` included; the month so far when left out), narrowed by `products` and `projects`: `totals` and what paid for it, each day, and each product's meters with their daily amounts and split by project. `group_by` (`product`, `project` or `day`) adds `groups`. | `workspace` | `billing:read` | | |
| 566 | + | | [`budget`](/reference/api/billing/get-budget/) | The monthly spend limit (`amount_micros`, or `automatic`), `spent_micros` this month, `max_amount_micros`, `alerts`, `pause_at_limit`, `webhook` and `state`. | `workspace` | `billing:read` | | |
| 567 | + | | [`set_budget`](/reference/api/billing/set-budget/) | Change the limit (`amount_micros`, null for the automatic one), `alerts` (some of 50, 75, 90 and 100), `pause_at_limit` or `webhook`. Fields left out keep their value. Owners, as people. | `workspace` | `billing:write` | | |
| 568 | + | | [`ai_credit`](/reference/api/billing/get-ai-credit/) | AI credit left, its grants, whether runs are `blocked` for want of it, auto-reload, and what can be bought. | `workspace` | `billing:read` | | |
| 569 | + | | [`buy_ai_credit`](/reference/api/billing/buy-ai-credit/) | A payment page (`url`) to buy `amount_cents` of credit, in whole dollars from $10 to $1,000, for a person to open and pay; it returns to the workspace's billing page. Owners, as people. | `workspace`, `amount_cents` | `billing:write` | | |
| 570 | + | | [`invoices`](/reference/api/billing/list-invoices/) | Every invoice (`invoices`, in cents), g1t's itemised usage invoices (`usage_invoices`), and what the next one comes to so far (`upcoming`). | `workspace` | `billing:read` | | |
| 571 | + | | [`billing_details`](/reference/api/billing/get-billing-details/) | Who invoices are made out to, and the payment method on file as far as it is safe to show. | `workspace` | `billing:read` | | |
| 572 | + | ||
| 552 | 573 | ## `notifications` | |
| 553 | 574 | ||
| 554 | 575 | Your [inbox](/guides/inbox/): one thread per issue, pull request, workflow | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.