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 audit | 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 | |
| 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 audit | 8 | //! 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 audit | 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 | ||
| 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. | |
| 22 | pub(crate) const PRODUCTS: [&str; 7] = ["sandboxes", "deployments", "git_storage", "packages", "security", "search", "other"]; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 23 | |
| 24 | /// Where a budget's alerts can be, in percent of its limit. | |
| 25 | pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100]; | |
| 26 | ||
| 27 | /// How usage can be added up over its range. | |
| 28 | pub(crate) const GROUPS: [&str; 3] = ["product", "project", "day"]; | |
| 29 | ||
| 30 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 31 | Ok(Outcome::fail(code, message)) | |
| 32 | } | |
| 33 | ||
| 34 | /// `camelCase` as `snake_case`. | |
| 35 | fn 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. | |
| 53 | pub(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). | |
| 65 | fn 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 | ||
| 74 | fn 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`. | |
| 79 | fn 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)`. | |
| 86 | fn 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`. | |
| 100 | pub(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 audit | 108 | pub(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 audit | 114 | )), |
| 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. | |
| 125 | pub(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 | ||
| 134 | fn 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). | |
| 141 | fn 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. | |
| 181 | pub(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)] | |
| 199 | pub(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 | ||
| 209 | pub(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. | |
| 259 | pub(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. | |
| 271 | pub(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. | |
| 292 | pub 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, ¤t) { | |
| 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)] | |
| 398 | mod 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 audit | 409 | { "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 audit | 411 | ], |
| 412 | "products": [ | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 413 | { "key": "other", "label": "Other", "micros": 12_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. | 414 | "meters": [{ "key": "other", "label": "Other", "product": "other", "unit": "entries", "quantity": 3.0, |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 415 | "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 }] }] }, | |
| The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens. | 418 | { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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 audit | 460 | 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!({}), ¤t).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 }), ¤t).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, ¤t).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(¤t); | |
| 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 audit | 505 | "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 tokens | 518 | 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 audit | 524 | |
| 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 tokens | 542 | name: None, |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 543 | ..TokenAccess::default() |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 544 | }; |
| 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 audit | 549 | } |
| 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 audit | 564 | } |
| 565 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.