Skip to content
565 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.1//! Billing: a workspace's usage, budget and invoices. The
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2//! 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
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.7//! included. Changing the budget is for its owners,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit8//! as people: signed in or with a personal access token. A workspace's
The API and MCP server no longer offer g1t's own agents. g1t is what your coding agents work against, not an agent of its own, so the operations that drove hosted agents are gone: messaging an agent on a pull request (message_agent, take_messages, answer_message), assigning or delegating an issue to g1t (assign_issue, delegate), plans (plan_work, get_plan, apply_plan), the context hub (search_context, get_entity), model routes, agents' computers, AI Gateway requests, "Fix with g1t" on a security alert, and every Customize route (marketplaces, plugins, connectors). The callbacks the agent sandbox reported through (/agent-runs/*, /runs/:id/usage, /plans/:id, /reviews/:id) go with them, update_repo_settings stops accepting agent_review, max_revisions and hold_low_confidence, import_issue stops accepting assign, and an old agent token with no run behind it is refused.9//! token and a run's token never change billing, whatever their scopes say.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit10
11use std::collections::BTreeMap;
12
13use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
14use serde_json::{Map, Value, json};
15use worker::Result;
16
17use crate::operations::{Op, Services};
18
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.19/// The product families usage is grouped into, in order. `other` holds
20/// usage on meters g1t no longer has, such as agent runs and model
21/// requests from before they were retired.
22pub(crate) const PRODUCTS: [&str; 7] = ["sandboxes", "deployments", "git_storage", "packages", "security", "search", "other"];
Usage, Billing settings and prepaid AI credit; fixes from the UX audit23
24/// Where a budget's alerts can be, in percent of its limit.
25pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100];
26
27/// How usage can be added up over its range.
28pub(crate) const GROUPS: [&str; 3] = ["product", "project", "day"];
29
30fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
31 Ok(Outcome::fail(code, message))
32}
33
34/// `camelCase` as `snake_case`.
35fn snake_key(key: &str) -> String {
36 let mut out = String::with_capacity(key.len() + 4);
37 for c in key.chars() {
38 if c.is_ascii_uppercase() {
39 if !out.is_empty() {
40 out.push('_');
41 }
42 out.push(c.to_ascii_lowercase());
43 } else {
44 out.push(c);
45 }
46 }
47 out
48}
49
50/// A service's answer with every object key in `snake_case`, all the way
51/// down. Billing's answers have no keys that are data, so all of them are
52/// names.
53pub(crate) fn snake(value: &Value) -> Value {
54 match value {
55 Value::Object(fields) => {
56 Value::Object(fields.iter().map(|(key, value)| (snake_key(key), snake(value))).collect())
57 }
58 Value::Array(items) => Value::Array(items.iter().map(snake).collect()),
59 other => other.clone(),
60 }
61}
62
63/// Strings given as an array, or as one string separated by commas (a
64/// query string's way).
65fn list(input: &Value, key: &str) -> Vec<String> {
66 let items: Vec<String> = match &input[key] {
67 Value::Array(items) => items.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect(),
68 Value::String(text) => text.split(',').map(str::to_owned).collect(),
69 _ => Vec::new(),
70 };
71 items.into_iter().map(|item| item.trim().to_owned()).filter(|item| !item.is_empty()).collect()
72}
73
74fn workspace(input: &Value) -> Option<String> {
75 input["workspace"].as_str().map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase)
76}
77
78/// `YYYY-MM-DD`.
79fn is_day(text: &str) -> bool {
80 let bytes = text.as_bytes();
81 bytes.len() == 10
82 && bytes.iter().enumerate().all(|(at, byte)| if at == 4 || at == 7 { *byte == b'-' } else { byte.is_ascii_digit() })
83}
84
85/// The UTC day `days` after 1970-01-01, as `(year, month, day)`.
86fn civil(days: i64) -> (i64, u32, u32) {
87 let z = days + 719_468;
88 let era = z.div_euclid(146_097);
89 let doe = z.rem_euclid(146_097);
90 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
91 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
92 let mp = (5 * doy + 2) / 153;
93 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
94 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
95 let year = yoe + era * 400 + i64::from(month <= 2);
96 (year, month, day)
97}
98
99/// The first day of the current UTC month, and today, at `now_ms`.
100pub(crate) fn this_month(now_ms: f64) -> (String, String) {
101 let (year, month, day) = civil((now_ms / 86_400_000.0).floor() as i64);
102 (format!("{year:04}-{month:02}-01"), format!("{year:04}-{month:02}-{day:02}"))
103}
104
105/// The person who may change a workspace's billing: a person, never a
The API and MCP server no longer offer g1t's own agents. g1t is what your coding agents work against, not an agent of its own, so the operations that drove hosted agents are gone: messaging an agent on a pull request (message_agent, take_messages, answer_message), assigning or delegating an issue to g1t (assign_issue, delegate), plans (plan_work, get_plan, apply_plan), the context hub (search_context, get_entity), model routes, agents' computers, AI Gateway requests, "Fix with g1t" on a security alert, and every Customize route (marketplaces, plugins, connectors). The callbacks the agent sandbox reported through (/agent-runs/*, /runs/:id/usage, /plans/:id, /reviews/:id) go with them, update_repo_settings stops accepting agent_review, max_revisions and hold_low_confidence, import_issue stops accepting assign, and an old agent token with no run behind it is refused.106/// workspace's own token or a run's token. `agent_scoped` is whether
107/// the request came with a run's token.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit108pub(crate) fn person(viewer: &Viewer, agent_scoped: bool) -> std::result::Result<&User, (FailureCode, &'static str)> {
109 match viewer {
110 None => Err((FailureCode::Unauthenticated, "This needs a g1t access token.")),
111 Some(user) if agent_scoped || user.kind == PrincipalKind::Agent => Err((
112 FailureCode::Forbidden,
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.113 "A run's token never changes billing: a workspace's budget is for its owners.",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit114 )),
115 Some(user) if user.kind != PrincipalKind::User => Err((
116 FailureCode::Forbidden,
117 "Changing billing needs a person: sign in, or use a personal access token. A workspace's own token can read billing, not change it.",
118 )),
119 Some(user) => Ok(user),
120 }
121}
122
123/// A usage report (camelCase, as billing answers) in its public shape,
124/// with `groups` when `group_by` asks for them.
125pub(crate) fn usage_json(report: &Value, group_by: Option<&str>) -> Value {
126 let mut out = snake(report);
127 if let (Some(by), Some(fields)) = (group_by, out.as_object_mut()) {
128 fields.insert("group_by".to_owned(), json!(by));
129 fields.insert("groups".to_owned(), groups(report, by));
130 }
131 out
132}
133
134fn micros(value: &Value) -> i64 {
135 value.as_i64().or_else(|| value.as_f64().map(|n| n as i64)).unwrap_or(0)
136}
137
138/// The report's range added up by product (every family, in order), by
139/// project (most first; `key` null for usage that is no one project's) or
140/// by day (oldest first).
141fn groups(report: &Value, by: &str) -> Value {
142 let products = report["products"].as_array().cloned().unwrap_or_default();
143 match by {
144 "product" => Value::Array(
145 products
146 .iter()
147 .map(|product| json!({ "key": product["key"], "label": product["label"], "micros": micros(&product["micros"]) }))
148 .collect(),
149 ),
150 "project" => {
151 let mut sums: BTreeMap<String, i64> = BTreeMap::new();
152 for product in &products {
153 for meter in product["meters"].as_array().into_iter().flatten() {
154 for part in meter["byProject"].as_array().into_iter().flatten() {
155 *sums.entry(part["project"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&part["micros"]);
156 }
157 }
158 }
159 let mut sums: Vec<(String, i64)> = sums.into_iter().collect();
160 sums.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
161 Value::Array(
162 sums.into_iter()
163 .map(|(project, micros)| {
164 let key = if project.is_empty() { Value::Null } else { Value::String(project) };
165 json!({ "key": key, "micros": micros })
166 })
167 .collect(),
168 )
169 }
170 _ => {
171 let mut sums: BTreeMap<String, i64> = BTreeMap::new();
172 for day in report["days"].as_array().into_iter().flatten() {
173 *sums.entry(day["day"].as_str().unwrap_or_default().to_owned()).or_default() += micros(&day["micros"]);
174 }
175 Value::Array(sums.into_iter().map(|(day, micros)| json!({ "key": day, "micros": micros })).collect())
176 }
177 }
178}
179
180/// A workspace's limit (camelCase, as billing answers) as its budget.
181pub(crate) fn budget_json(limit: &Value) -> Value {
182 json!({
183 "workspace": limit["workspace"],
184 "amount_micros": limit["spendLimitMicros"],
185 "automatic": limit["defaultSpendLimit"].as_bool().unwrap_or(false),
186 "spent_micros": micros(&limit["spentMicros"]),
187 "max_amount_micros": limit["availableMicros"],
188 "alerts": limit["alertLevels"].as_array().cloned().unwrap_or_default(),
189 "pause_at_limit": limit["pauseAtLimit"].as_bool().unwrap_or(true),
190 "webhook": limit["budgetWebhook"],
191 "state": limit["state"],
192 "message": limit["message"],
193 })
194}
195
196/// What set_budget asks billing for: the fields given, and the rest as
197/// they are in `current` (the workspace's limit, camelCase).
198#[derive(Debug, PartialEq)]
199pub(crate) struct BudgetChange {
200 /// No amount was given: billing leaves the limit as it is, whatever
201 /// it is by the time it is asked.
202 pub keep_limit: bool,
203 pub amount_micros: Option<i64>,
204 pub alerts: Vec<u32>,
205 pub pause_at_limit: bool,
206 pub webhook: Option<String>,
207}
208
209pub(crate) fn budget_change(input: &Value, current: &Value) -> std::result::Result<BudgetChange, String> {
210 let amount_micros = match input.get("amount_micros") {
211 None if current["defaultSpendLimit"].as_bool() == Some(true) => None,
212 None => current["spendLimitMicros"].as_i64(),
213 Some(Value::Null) => None,
214 Some(value) => {
215 let amount = value.as_i64().or_else(|| value.as_str().and_then(|digits| digits.trim().parse().ok()));
216 match amount {
217 Some(amount) if amount >= 0 => Some(amount),
218 _ => return Err("amount_micros is a whole number of millionths of a dollar, or null for the automatic limit.".to_owned()),
219 }
220 }
221 };
222 let alerts = match input.get("alerts") {
223 None | Some(Value::Null) => current["alertLevels"]
224 .as_array()
225 .map(|levels| levels.iter().filter_map(|level| level.as_u64()).filter_map(|level| u32::try_from(level).ok()).collect())
226 .unwrap_or_default(),
227 Some(Value::Array(levels)) => {
228 let mut chosen = Vec::new();
229 for level in levels {
230 let level = level.as_u64().or_else(|| level.as_str().and_then(|digits| digits.trim().parse().ok()));
231 match level.and_then(|level| u32::try_from(level).ok()).filter(|level| ALERT_LEVELS.contains(level)) {
232 Some(level) if !chosen.contains(&level) => chosen.push(level),
233 Some(_) => {}
234 None => return Err("alerts are some of 50, 75, 90 and 100.".to_owned()),
235 }
236 }
237 chosen.sort_unstable();
238 chosen
239 }
240 Some(_) => return Err("alerts is a list: some of 50, 75, 90 and 100.".to_owned()),
241 };
242 let pause_at_limit = match input.get("pause_at_limit") {
243 None | Some(Value::Null) => current["pauseAtLimit"].as_bool().unwrap_or(true),
244 Some(Value::Bool(pause)) => *pause,
245 Some(Value::String(word)) if word == "true" || word == "false" => word == "true",
246 Some(_) => return Err("pause_at_limit is true or false.".to_owned()),
247 };
248 let webhook = match input.get("webhook") {
249 None => current["budgetWebhook"].as_str().map(str::to_owned),
250 Some(Value::Null) => None,
251 Some(Value::String(url)) if url.trim().is_empty() => None,
252 Some(Value::String(url)) if url.trim().starts_with("https://") => Some(url.trim().to_owned()),
253 Some(_) => return Err("webhook is an https:// address, or null for none.".to_owned()),
254 };
255 Ok(BudgetChange { keep_limit: input.get("amount_micros").is_none(), amount_micros, alerts, pause_at_limit, webhook })
256}
257
258/// Billing details without the invoices, which list_invoices gives.
259pub(crate) fn details_json(details: &Value) -> Value {
260 let mut out = snake(details);
261 if let Some(fields) = out.as_object_mut() {
262 fields.remove("invoices");
263 fields.remove("upcoming");
264 fields.remove("unavailable");
265 }
266 out
267}
268
269/// Every invoice billed to the workspace, g1t's itemised usage invoices,
270/// and what the next one comes to so far.
271pub(crate) fn invoices_json(details: &Value, usage: &[Value]) -> Value {
272 let usage: Vec<Value> = usage
273 .iter()
274 .map(|invoice| {
275 let mut fields = Map::new();
276 fields.insert("id".to_owned(), invoice["invoiceId"].clone());
277 if let Value::Object(rest) = snake(invoice) {
278 fields.extend(rest.into_iter().filter(|(key, _)| key != "invoice_id"));
279 }
280 Value::Object(fields)
281 })
282 .collect();
283 json!({
284 "invoices": snake(&details["invoices"]).as_array().cloned().unwrap_or_default(),
285 "usage_invoices": usage,
286 "upcoming": snake(&details["upcoming"]),
287 "unavailable": details["unavailable"],
288 })
289}
290
291/// Runs one of the billing operations.
292pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
293 if viewer.is_none() {
294 return failed(FailureCode::Unauthenticated, "This needs a g1t access token.");
295 }
296 let Some(workspace) = workspace(input) else {
297 return failed(FailureCode::Invalid, "Give the workspace's slug.");
298 };
299 let billing = &services.billing;
300 let shaped = |outcome: Outcome<Value>, shape: &dyn Fn(&Value) -> Value| -> Result<Outcome<Value>> {
301 Ok(match outcome {
302 Outcome::Ok(value) => Outcome::Ok(shape(&value)),
303 Outcome::Fail(failure) => Outcome::Fail(failure),
304 })
305 };
306 let account = json!({ "workspace": workspace, "viewer": viewer });
307 match op {
308 Op::GetUsage => {
309 let group_by = input["group_by"].as_str().map(str::trim).filter(|by| !by.is_empty()).map(str::to_lowercase);
310 if let Some(by) = &group_by
311 && !GROUPS.contains(&by.as_str())
312 {
313 return failed(FailureCode::Invalid, "group_by is product, project or day.");
314 }
315 let products = list(input, "products");
316 if let Some(unknown) = products.iter().find(|product| !PRODUCTS.contains(&product.as_str())) {
317 return failed(
318 FailureCode::Invalid,
319 &format!("{unknown} is not a product. Give some of {}.", PRODUCTS.join(", ")),
320 );
321 }
322 let (month_start, today) = this_month(worker::Date::now().as_millis() as f64);
323 let day = |key: &str, default: String| -> std::result::Result<String, String> {
324 match input[key].as_str().map(str::trim).filter(|day| !day.is_empty()) {
325 None => Ok(default),
326 Some(day) if is_day(day) => Ok(day.to_owned()),
327 Some(day) => Err(format!("{key} is a day, YYYY-MM-DD, not {day}.")),
328 }
329 };
330 let (from, until) = match (day("from", month_start), day("until", today)) {
331 (Ok(from), Ok(until)) => (from, until),
332 (Err(message), _) | (_, Err(message)) => return failed(FailureCode::Invalid, &message),
333 };
334 let report: Outcome<Value> = g1t_kit::call(
335 billing,
336 "usage_report",
337 &json!({
338 "workspace": workspace,
339 "viewer": viewer,
340 "from": from,
341 "until": until,
342 "products": products,
343 "projects": list(input, "projects"),
344 }),
345 )
346 .await?;
347 shaped(report, &|report| usage_json(report, group_by.as_deref()))
348 }
349 Op::GetBudget => shaped(g1t_kit::call(billing, "limit", &account).await?, &budget_json),
350 Op::SetBudget => {
351 let actor = match person(viewer, services.scope.is_some()) {
352 Ok(user) => user,
353 Err((code, message)) => return failed(code, message),
354 };
355 let current: Outcome<Value> = g1t_kit::call(billing, "limit", &account).await?;
356 let current = match current {
357 Outcome::Ok(limit) => limit,
358 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
359 };
360 let change = match budget_change(input, &current) {
361 Ok(change) => change,
362 Err(message) => return failed(FailureCode::Invalid, &message),
363 };
364 let set: Outcome<Value> = g1t_kit::call(
365 billing,
366 "set_budget",
367 &json!({
368 "actor": actor,
369 "workspace": workspace,
370 "keepLimit": change.keep_limit,
371 "amountMicros": change.amount_micros,
372 "alerts": change.alerts,
373 "pauseAtLimit": change.pause_at_limit,
374 "webhook": change.webhook,
375 }),
376 )
377 .await?;
378 shaped(set, &budget_json)
379 }
380 Op::ListInvoices => {
381 let details: Outcome<Value> = g1t_kit::call(billing, "billing_details", &account).await?;
382 let details = match details {
383 Outcome::Ok(details) => details,
384 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
385 };
386 let usage: Outcome<Vec<Value>> = g1t_kit::call(billing, "invoices", &account).await?;
387 Ok(match usage {
388 Outcome::Ok(usage) => Outcome::Ok(invoices_json(&details, &usage)),
389 Outcome::Fail(failure) => Outcome::Fail(failure),
390 })
391 }
392 Op::GetBillingDetails => shaped(g1t_kit::call(billing, "billing_details", &account).await?, &details_json),
393 _ => failed(FailureCode::Invalid, "Not a billing operation."),
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn a_usage_report_is_snake_case_all_the_way_down() {
403 let report = json!({
404 "from": "2026-10-01", "until": "2026-10-07",
405 "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000,
406 "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 },
407 "days": [
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.408 { "day": "2026-10-02", "product": "other", "micros": 4_000_000 },
Usage, Billing settings and prepaid AI credit; fixes from the UX audit409 { "day": "2026-10-02", "product": "sandboxes", "micros": 500_000 },
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.410 { "day": "2026-10-01", "product": "other", "micros": 8_000_000 },
Usage, Billing settings and prepaid AI credit; fixes from the UX audit411 ],
412 "products": [
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.413 { "key": "other", "label": "Other", "micros": 12_000_000, "features": [],
414 "meters": [{ "key": "other", "label": "Other", "product": "other", "unit": "entries", "quantity": 3.0,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit415 "micros": 12_000_000, "pendingMicros": 0, "daily": [8_000_000, 4_000_000], "allowance": null,
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.416 "byProject": [{ "project": "acme/web", "micros": 9_000_000, "quantity": 2.0 },
417 { "project": "", "micros": 3_000_000, "quantity": 1.0 }] }] },
Usage, Billing settings and prepaid AI credit; fixes from the UX audit418 { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [],
419 "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0,
420 "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000],
421 "allowance": { "used": 600.0, "of": 3600.0, "unit": "seconds" },
422 "byProject": [{ "project": "acme/web", "micros": 500_000, "quantity": 600.0 }] }] },
423 ],
424 "projects": ["acme/web"], "included": null, "discountPercent": null,
425 "aiCreditMicros": 40_000_000, "creditMicros": 0, "trialMicros": null, "plan": "pro", "free": false,
426 });
427 let usage = usage_json(&report, None);
428 assert_eq!(usage["totals"]["charged_micros"], 10_000_000);
429 assert_eq!(usage["products"][0]["meters"][0]["by_project"][0]["project"], "acme/web");
430 assert_eq!(usage["products"][0]["meters"][0]["pending_micros"], 0);
431 assert_eq!(usage["ai_credit_micros"], 40_000_000);
432 assert!(usage.get("groups").is_none());
433 let text = usage.to_string();
434 for camel in ["Micros", "byProject", "discountPercent"] {
435 assert!(!text.contains(camel), "{camel} in {text}");
436 }
437
438 let by_project = usage_json(&report, Some("project"));
439 assert_eq!(by_project["group_by"], "project");
440 assert_eq!(
441 by_project["groups"],
442 json!([{ "key": "acme/web", "micros": 9_500_000 }, { "key": null, "micros": 3_000_000 }])
443 );
444 let by_day = usage_json(&report, Some("day"));
445 assert_eq!(by_day["groups"], json!([{ "key": "2026-10-01", "micros": 8_000_000 }, { "key": "2026-10-02", "micros": 4_500_000 }]));
446 let by_product = usage_json(&report, Some("product"));
447 assert_eq!(by_product["groups"][1], json!({ "key": "sandboxes", "label": "Sandboxes", "micros": 500_000 }));
448 }
449
450 #[test]
451 fn agents_and_workspace_tokens_never_change_billing() {
452 let person_user = User { username: "ana".into(), ..User::default() };
453 assert!(person(&Some(person_user.clone()), false).is_ok());
454 // A person's token used by an agent's run is still an agent's.
455 assert_eq!(person(&Some(person_user), true).unwrap_err().0, FailureCode::Forbidden);
456 let agent = User { username: "g1t".into(), kind: PrincipalKind::Agent, ..User::default() };
457 let (code, message) = person(&Some(agent), false).unwrap_err();
458 assert_eq!(code, FailureCode::Forbidden);
The API and MCP server no longer offer g1t's own agents. g1t is what your coding agents work against, not an agent of its own, so the operations that drove hosted agents are gone: messaging an agent on a pull request (message_agent, take_messages, answer_message), assigning or delegating an issue to g1t (assign_issue, delegate), plans (plan_work, get_plan, apply_plan), the context hub (search_context, get_entity), model routes, agents' computers, AI Gateway requests, "Fix with g1t" on a security alert, and every Customize route (marketplaces, plugins, connectors). The callbacks the agent sandbox reported through (/agent-runs/*, /runs/:id/usage, /plans/:id, /reviews/:id) go with them, update_repo_settings stops accepting agent_review, max_revisions and hold_low_confidence, import_issue stops accepting assign, and an old agent token with no run behind it is refused.459 assert!(message.contains("never changes billing"));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit460 let workspace = User { username: "acme".into(), kind: PrincipalKind::Workspace, ..User::default() };
461 let (code, message) = person(&Some(workspace), false).unwrap_err();
462 assert_eq!(code, FailureCode::Forbidden);
463 assert!(message.contains("personal access token"));
464 assert_eq!(person(&None, false).unwrap_err().0, FailureCode::Unauthenticated);
465 }
466
467 #[test]
468 fn a_budget_change_keeps_what_was_not_given() {
469 let current = json!({
470 "workspace": "acme", "spendLimitMicros": 300_000_000, "defaultSpendLimit": false, "spentMicros": 12_000_000,
471 "availableMicros": 1_000_000_000, "alertLevels": [100, 75, 50], "pauseAtLimit": true,
472 "budgetWebhook": "https://acme.dev/hooks/budget", "state": "ok", "message": null,
473 });
474 let kept = budget_change(&json!({}), &current).unwrap();
475 assert_eq!(
476 kept,
477 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()) }
478 );
479 let changed = budget_change(&json!({ "amount_micros": null, "alerts": [90, 50, 90], "pause_at_limit": false, "webhook": null }), &current).unwrap();
480 assert_eq!(changed, BudgetChange { keep_limit: false, amount_micros: None, alerts: vec![50, 90], pause_at_limit: false, webhook: None });
481 for bad in [json!({ "alerts": [60] }), json!({ "alerts": "50" }), json!({ "amount_micros": -1 }), json!({ "webhook": "http://x" }), json!({ "pause_at_limit": "yes" })] {
482 assert!(budget_change(&bad, &current).is_err(), "{bad}");
483 }
484 // The automatic limit stays automatic when no amount is given.
485 let automatic = json!({ "spendLimitMicros": 200_000_000, "defaultSpendLimit": true });
486 assert_eq!(budget_change(&json!({}), &automatic).unwrap().amount_micros, None);
487 let budget = budget_json(&current);
488 assert_eq!(budget["amount_micros"], 300_000_000);
489 assert_eq!(budget["max_amount_micros"], 1_000_000_000);
490 assert_eq!(budget["alerts"], json!([100, 75, 50]));
491 assert_eq!(budget["automatic"], false);
492 }
493
494 #[test]
495 fn invoices_put_every_invoice_beside_the_itemised_usage_ones() {
496 let details = json!({
497 "customer": true, "email": "billing@acme.dev",
498 "invoices": [{ "id": "in_1", "number": "ACME-0001", "status": "paid", "totalCents": 2000, "currency": "usd",
499 "createdAt": "2026-10-01T00:00:00Z", "description": null, "hostedUrl": null, "pdfUrl": null }],
500 "upcoming": { "closesAt": "2026-11-01T00:00:00Z", "subscriptionsMicros": 20_000_000, "usageMicros": 5_000_000, "totalMicros": 25_000_000 },
501 "unavailable": null,
502 });
503 let usage = [json!({ "invoiceId": "inv_1", "workspace": "acme", "reason": "month", "period": "2026-09", "amountMicros": 5_000_000,
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.504 "status": "paid", "hostedUrl": null, "pdfUrl": null, "lines": [{ "description": "Sandbox time", "amountMicros": 5_000_000 }],
Usage, Billing settings and prepaid AI credit; fixes from the UX audit505 "createdAt": "2026-10-01T00:00:00Z" })];
506 let out = invoices_json(&details, &usage);
507 assert_eq!(out["invoices"][0]["total_cents"], 2000);
508 assert_eq!(out["usage_invoices"][0]["id"], "inv_1");
509 assert!(out["usage_invoices"][0].get("invoice_id").is_none());
510 assert_eq!(out["usage_invoices"][0]["lines"][0]["amount_micros"], 5_000_000);
511 assert_eq!(out["upcoming"]["total_micros"], 25_000_000);
512 let shown = details_json(&details);
513 assert_eq!(shown["email"], "billing@acme.dev");
514 assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none());
515 }
516
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.517 const OPS: [Op; 5] = [
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens518 Op::GetUsage,
519 Op::GetBudget,
520 Op::SetBudget,
521 Op::ListInvoices,
522 Op::GetBillingDetails,
523 ];
Usage, Billing settings and prepaid AI credit; fixes from the UX audit524
525 /// Billing belongs to a workspace, needs someone signed in, and is one
526 /// MCP tool whose writes no preset but full access reaches.
527 #[test]
528 fn billing_operations_name_a_workspace_and_agents_only_read() {
529 use crate::tools::{Gate, Tool};
530 use g1t_contracts::scopes::{Preset, TokenAccess, scope_for};
531 for op in OPS {
532 assert!(!op.needs_repo(), "{}", op.name());
533 assert!(op.needs_user(), "{}", op.name());
534 assert!(op.required().contains(&"workspace".to_owned()), "{}", op.name());
535 assert!(scope_for(op.name()).is_some(), "{}", op.name());
536 }
537 let tool = Tool::by_name("billing").unwrap();
538 let token = |preset: Preset| TokenAccess {
539 token_id: "tok_1".into(),
540 scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
541 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens542 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'543 ..TokenAccess::default()
Usage, Billing settings and prepaid AI credit; fixes from the UX audit544 };
545 for preset in [Preset::ReadOnly, Preset::Agent] {
546 let access = token(preset);
547 let seen: Vec<&str> = tool.visible(&Gate::Token(&access)).iter().map(|action| action.name).collect();
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.548 assert_eq!(seen, ["usage", "budget", "invoices", "billing_details"], "{}", preset.as_str());
Usage, Billing settings and prepaid AI credit; fixes from the UX audit549 }
550 let full = TokenAccess::full();
551 assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len());
552 }
553
554 #[test]
555 fn the_month_so_far_is_read_from_the_clock() {
556 // 2026-10-07T12:00:00Z.
557 assert_eq!(this_month(1_791_374_400_000.0), ("2026-10-01".to_owned(), "2026-10-07".to_owned()));
558 assert_eq!(this_month(0.0), ("1970-01-01".to_owned(), "1970-01-01".to_owned()));
559 // 2024-02-29.
560 assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned()));
561 assert!(is_day("2026-10-07") && !is_day("2026-10-7") && !is_day("20261007xx"));
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.562 assert_eq!(list(&json!({ "products": "sandboxes, search,," }), "products"), vec!["sandboxes", "search"]);
563 assert_eq!(list(&json!({ "products": ["other"] }), "products"), vec!["other"]);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit564 }
565}

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