Skip to content

Commit

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.

syntaqxcommitted Parent4050271Browse files
114 files+1382−660/114 viewed
+603−0
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, &current) {
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!({}), &current).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 }), &current).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, &current).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(&current);
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+}
+1−0
77 mod addresses;
88 mod alerts;
99 mod audit;
10+mod billing;
1011 mod blobs;
1112 mod mcp;
1213 mod notifications;
+20−0
6262 ],
6363 ),
6464 (
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+ (
6578 "Repositories",
6679 "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it.",
6780 &[
492505 Op::DeleteRepoSubscription => "Stop watching a repository",
493506 Op::ListWatchedRepos => "List repositories you watch",
494507 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",
495515 Op::PinProject => "Pin a project",
496516 Op::UnpinProject => "Unpin a project",
497517 Op::ReorderPinnedProjects => "Reorder your pinned projects",
+119−1
249249 RemoveTeamRepo,
250250 SetTeamReviewAssignment,
251251 ListUserTeams,
252+ GetUsage,
253+ GetBudget,
254+ SetBudget,
255+ GetAiCredit,
256+ BuyAiCredit,
257+ ListInvoices,
258+ GetBillingDetails,
252259 RequestReviewers,
253260 RemoveRequestedReviewers,
254261 GetCodeownersErrors,
616623 }
617624
618625 impl Op {
619− pub const ALL: [Op; 197] = [
626+ pub const ALL: [Op; 204] = [
620627 Op::Whoami,
621628 Op::CreateWorkspace,
622629 Op::DeleteWorkspace,
780787 Op::RemoveTeamRepo,
781788 Op::SetTeamReviewAssignment,
782789 Op::ListUserTeams,
790+ Op::GetUsage,
791+ Op::GetBudget,
792+ Op::SetBudget,
793+ Op::GetAiCredit,
794+ Op::BuyAiCredit,
795+ Op::ListInvoices,
796+ Op::GetBillingDetails,
783797 Op::RequestReviewers,
784798 Op::RemoveRequestedReviewers,
785799 Op::GetCodeownersErrors,
9861000 Op::RemoveTeamRepo => "remove_team_repo",
9871001 Op::SetTeamReviewAssignment => "set_team_review_assignment",
9881002 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",
9891010 Op::RequestReviewers => "request_reviewers",
9901011 Op::RemoveRequestedReviewers => "remove_requested_reviewers",
9911012 Op::GetCodeownersErrors => "get_codeowners_errors",
14471468 Op::SetTeamReviewAssignment => {
14481469 "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."
14491470 }
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+ }
14501492 Op::ListUserTeams => {
14511493 "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."
14521494 }
26732715 object(properties, &required)
26742716 }
26752717 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+ ),
26762778 Op::ListUserTeams => object(
26772779 json!({ "workspace": workspace_schema(), "username": username_schema() }),
26782780 &["workspace", "username"],
28132915 | Op::RemoveTeamRepo
28142916 | Op::SetTeamReviewAssignment
28152917 | Op::ListUserTeams
2918+ | Op::GetUsage
2919+ | Op::GetBudget
2920+ | Op::SetBudget
2921+ | Op::GetAiCredit
2922+ | Op::BuyAiCredit
2923+ | Op::ListInvoices
2924+ | Op::GetBillingDetails
28162925 )
28172926 }
28182927
45654674 )
45664675 .await
45674676 }
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,
45684686 Op::ListUserTeams => {
45694687 pass(
45704688 identity,
+370−0
56345634 }
56355635 ]
56365636 },
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+ },
56376007 "list_pinned_projects": {
56386008 "params": {
56396009 "workspace": "flagon-io"
+30−0
120120 &[],
121121 ),
122122 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, &[]),
123136 // Code owners: the CODEOWNERS file, checked.
124137 route("GET", "/repos/:owner/:name/codeowners/errors", Op::GetCodeownersErrors, &[("ref", "ref")]),
125138 // Security alerts: secrets and vulnerable dependencies.
10031016 }
10041017
10051018 #[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]
10061036 fn query_parameters_are_renamed() {
10071037 let query = [
10081038 ("q".to_owned(), "parser".to_owned()),
+16−1
289289 ],
290290 },
291291 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 {
292307 name: "security",
293308 title: "Security",
294309 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.",
646661 assert!(tool.action(default).is_some(), "{}", tool.name);
647662 }
648663 }
649− assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len());
664+ assert!(TOOLS.len() <= 17, "{} tools", TOOLS.len());
650665 }
651666
652667 #[test]
+3−0
326326 | Notifications | `notifications:read`, `notifications:write` |
327327 | Security | `security:read`, `security:write` |
328328 | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` |
329+| Billing | `billing:read`, `billing:write` |
329330 | Runners | `runners:read` |
330331 | Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` |
331332
360361 | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories |
361362 | `workspace:read` | Read workspace invites, integrations, model routes and [teams](/guides/teams/) |
362363 | `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. |
363366 | `access:read` | See who has access to repositories |
364367 | `access:admin` | Give people and teams access to repositories, and take it away |
365368 | `webhooks:read` | See webhooks and their deliveries |
+10−2
3232 repository is [transferred](/guides/transferring-repositories/#deployments)
3333 to another workspace or [renamed](/guides/managing-repositories/), its apps
3434 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**.
3639
3740 When the [default branch changes](/guides/managing-repositories/), production
3841 is built again from the new default branch. An
164167 A newer push replaces a build that is still running for the same pull
165168 request. Previews are marked `noindex`, so search engines leave them alone.
166169
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+
167174 ## Production
168175
169176 Each push to the default branch, which is each merge on a protected
381388
382389 ### Seeing what you use
383390
384−- **Billing**, under the plan, shows **This month's usage**: *Builds*
391+- **Usage** shows **Deployments** with *Builds*
385392 (build minutes and their cost), *Requests & CPU* and *Custom domains*,
386393 in dollars, beside the rest of the workspace's usage. Requests and CPU
387394 time are counted from Cloudflare's analytics every 10 minutes.
448455 | "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. |
449456 | 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**. |
450457 | 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. |
451459 | "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**. |
452460
453461 ## Running your own g1t
+6−3
121121 every branch: the default one first, then those with a commit in the last 90
122122 days (**Active**), then the rest (**Stale**). Each shows its last commit, how
123123 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.
127130
128131 The **Tags** tab lists tags newest first, up to 100, each with its commit
129132 and a ZIP of its files.
+7−5
143143 first. Each shows its last commit and who made it, how many commits it is
144144 ahead of the default branch and behind it, and its open pull request,
145145 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**.
147148
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.
152154
153155 ## Settings
154156
+168−50
7474
7575 ### What it costs
7676
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).
7980
8081 | What | Unit | Costs g1t | You pay |
8182 | --- | --- | --- | --- |
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 |
8386 | Sandbox time (agents, workflows, the merge queue) | Second | About $0.001 a minute | About $0.0012 a minute |
8487 | [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% |
8588 | Deploy builds | Second | About $0.001 a minute | About $0.0012 a minute |
113116 3. If the workspace already has a checked card, the plan starts on it at
114117 once. Otherwise, pay on the card page you are sent to.
115118
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.
132136
133137 A card that says **100% discount from g1t** or **Included by g1t** is on
134138 under terms g1t set with the workspace, such as a
220224 | Repositories, issues, pull requests, review, search, the API and MCP | No |
221225
222226 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.
224233
225234 Work a workspace routes to [its own model providers](/guides/models/) is
226235 paid for at those providers instead. Such a run is charged here only for
230239 assigned the issue. That is why putting g1t to work on a
231240 repository needs the Write [role](/guides/access-and-roles/) or higher on it.
232241
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+
233258 ## How prices are set
234259
235260 Every price is what g1t pays for the thing, at Cloudflare or a model
460485 and by email, and the section shows the request and its answer. An approved amount becomes a
461486 floor under your ceiling, and your spend limit can go up to it.
462487
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+
463502 ### Prepay
464503
465504 Paying in advance pays for usage as it happens, after the plan's included
529568 person. The credit appears on the statement with the day it happened,
530569 such as *Credit from g1t: accidental usage on 2026-11-12*.
531570
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+
532620 ## Credits from g1t
533621
534622 g1t sometimes adds credit to a workspace: a welcome or referral credit, an
592680
593681 ## Your card and billing details
594682
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**:
600685
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+
601702 If a card is declined, work stops until the workspace pays, and the
602703 Billing page and the API say why. Replace the card or prepay to clear it.
603704 A payment disputed with the card's bank stops work the same way.
604705
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.
608709
609710 ## When work is stopped
610711
676777
677778 ## The Usage page
678779
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.
685784
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.
689788
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:
692790
693−| | |
791+| Filter | Choices |
694792 | --- | --- |
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.
704815
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+
705821 ## The statement
706822
707823 The **Billing** page ends with the workspace's statement, a month at a
784900 month's usage in six lines, `agents`, `builds`, `requests`, `domains`,
785901 `git_storage` and `search_scans`, each with `micros` (at cost plus 20%,
786902 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.
788906
789907 **`reserve`** holds the work's estimated cost before it starts, so starts
790908 at the same moment cannot overshoot together. `kind` is `agent`, `check`,
+5−2
345345 ## The sidebar
346346
347347 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**.
350353 [Explore](https://g1t.sh/explore), public projects from all of g1t, is in
351354 the top bar, beside **Docs**.
352355
+1−0
197197 | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. |
198198 | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. |
199199 | [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/). |
200201 | [Invites](/reference/api/invites/list-invites/) | Your invites while g1t is invite-only, and inviting people into a workspace by email. |
201202 | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. |
202203 | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. |
+23−2
33 description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them.
44 ---
55
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
77 thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`,
88 `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
1010 action is the same operation as a route of the [REST API](/reference/api/),
1111 with the same inputs, permissions and results, so the two always agree.
1212
549549 | [`unpin_project`](/reference/api/pinned-projects/unpin-project/) | Unpin it. Returns your pins. | `workspace`, `project` | `account:write` |
550550 | [`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` |
551551
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+
552573 ## `notifications`
553574
554575 Your [inbox](/guides/inbox/): one thread per issue, pull request, workflow
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.