Skip to content

Commit

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.

syntaqxcommitted Parent3e33b30Browse files
49 files+88−319333 files not counted0/49 viewed
+18−65
1−//! Billing: a workspace's usage, budget, AI credit and invoices. The
1+//! Billing: a workspace's usage, budget and invoices. The
22 //! billing service keeps them and answers in camelCase; this is their
33 //! public shape, in snake_case, with money as whole millionths of a dollar
44 //! (`_micros`) or, for invoices, cents (`_cents`).
55 //!
66 //! 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,
7+//! included. Changing the budget is for its owners,
88 //! as people: signed in or with a personal access token. A workspace's
99 //! token and a run's token never change billing, whatever their scopes say.
1010
1616
1717 use crate::operations::{Op, Services};
1818
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"];
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"];
2223
2324 /// Where a budget's alerts can be, in percent of its limit.
2425 pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100];
109110 None => Err((FailureCode::Unauthenticated, "This needs a g1t access token.")),
110111 Some(user) if agent_scoped || user.kind == PrincipalKind::Agent => Err((
111112 FailureCode::Forbidden,
112− "A run's token never changes billing: a workspace's budget and AI credit are for its owners.",
113+ "A run's token never changes billing: a workspace's budget is for its owners.",
113114 )),
114115 Some(user) if user.kind != PrincipalKind::User => Err((
115116 FailureCode::Forbidden,
375376 )
376377 .await?;
377378 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"] }))
401379 }
402380 Op::ListInvoices => {
403381 let details: Outcome<Value> = g1t_kit::call(billing, "billing_details", &account).await?;
427405 "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000,
428406 "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 },
429407 "days": [
430− { "day": "2026-10-02", "product": "agent", "micros": 4_000_000 },
408+ { "day": "2026-10-02", "product": "other", "micros": 4_000_000 },
431409 { "day": "2026-10-02", "product": "sandboxes", "micros": 500_000 },
432− { "day": "2026-10-01", "product": "agent", "micros": 8_000_000 },
410+ { "day": "2026-10-01", "product": "other", "micros": 8_000_000 },
433411 ],
434412 "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,
413+ { "key": "other", "label": "Other", "micros": 12_000_000, "features": [],
414+ "meters": [{ "key": "other", "label": "Other", "product": "other", "unit": "entries", "quantity": 3.0,
437415 "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 }] }] },
416+ "byProject": [{ "project": "acme/web", "micros": 9_000_000, "quantity": 2.0 },
417+ { "project": "", "micros": 3_000_000, "quantity": 1.0 }] }] },
440418 { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [],
441419 "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0,
442420 "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000],
470448 }
471449
472450 #[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]
496451 fn agents_and_workspace_tokens_never_change_billing() {
497452 let person_user = User { username: "ana".into(), ..User::default() };
498453 assert!(person(&Some(person_user.clone()), false).is_ok());
546501 "unavailable": null,
547502 });
548503 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 }],
504+ "status": "paid", "hostedUrl": null, "pdfUrl": null, "lines": [{ "description": "Sandbox time", "amountMicros": 5_000_000 }],
550505 "createdAt": "2026-10-01T00:00:00Z" })];
551506 let out = invoices_json(&details, &usage);
552507 assert_eq!(out["invoices"][0]["total_cents"], 2000);
559514 assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none());
560515 }
561516
562− const OPS: [Op; 7] = [
517+ const OPS: [Op; 5] = [
563518 Op::GetUsage,
564519 Op::GetBudget,
565520 Op::SetBudget,
566− Op::GetAiCredit,
567− Op::BuyAiCredit,
568521 Op::ListInvoices,
569522 Op::GetBillingDetails,
570523 ];
592545 for preset in [Preset::ReadOnly, Preset::Agent] {
593546 let access = token(preset);
594547 let seen: Vec<&str> = tool.visible(&Gate::Token(&access)).iter().map(|action| action.name).collect();
595− assert_eq!(seen, ["usage", "budget", "ai_credit", "invoices", "billing_details"], "{}", preset.as_str());
548+ assert_eq!(seen, ["usage", "budget", "invoices", "billing_details"], "{}", preset.as_str());
596549 }
597550 let full = TokenAccess::full();
598551 assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len());
606559 // 2024-02-29.
607560 assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned()));
608561 assert!(is_day("2026-10-07") && !is_day("2026-10-7") && !is_day("20261007xx"));
609− assert_eq!(list(&json!({ "products": "agent, sandboxes,," }), "products"), vec!["agent", "sandboxes"]);
610− assert_eq!(list(&json!({ "products": ["agent"] }), "products"), vec!["agent"]);
562+ assert_eq!(list(&json!({ "products": "sandboxes, search,," }), "products"), vec!["sandboxes", "search"]);
563+ assert_eq!(list(&json!({ "products": ["other"] }), "products"), vec!["other"]);
611564 }
612565 }
+1−5
101101 ),
102102 (
103103 "Billing",
104− "A workspace's usage, its budget, its AI credit and its invoices. Members read them; owners change the budget and buy credit, as people. A run's token never changes billing.",
104+ "A workspace's usage, its budget and its invoices. Members read them; owners change the budget, as people. A run's token never changes billing.",
105105 &[
106106 Op::GetUsage,
107107 Op::GetBudget,
108108 Op::SetBudget,
109− Op::GetAiCredit,
110− Op::BuyAiCredit,
111109 Op::ListInvoices,
112110 Op::GetBillingDetails,
113111 ],
759757 Op::GetUsage => "Get a workspace's usage",
760758 Op::GetBudget => "Get a workspace's budget",
761759 Op::SetBudget => "Change a workspace's budget",
762− Op::GetAiCredit => "Get a workspace's AI credit",
763− Op::BuyAiCredit => "Buy AI credit",
764760 Op::ListInvoices => "List a workspace's invoices",
765761 Op::GetBillingDetails => "Get a workspace's billing details",
766762 Op::PinProject => "Pin a project",
+4−33
283283 GetUsage,
284284 GetBudget,
285285 SetBudget,
286− GetAiCredit,
287− BuyAiCredit,
288286 ListInvoices,
289287 GetBillingDetails,
290288 RequestReviewers,
693691 }
694692
695693 impl Op {
696− pub const ALL: [Op; 368] = [
694+ pub const ALL: [Op; 366] = [
697695 Op::Whoami,
698696 Op::GetWorkspace,
699697 Op::CreateWorkspace,
868866 Op::GetUsage,
869867 Op::GetBudget,
870868 Op::SetBudget,
871− Op::GetAiCredit,
872− Op::BuyAiCredit,
873869 Op::ListInvoices,
874870 Op::GetBillingDetails,
875871 Op::RequestReviewers,
12451241 Op::GetUsage => "get_usage",
12461242 Op::GetBudget => "get_budget",
12471243 Op::SetBudget => "set_budget",
1248− Op::GetAiCredit => "get_ai_credit",
1249− Op::BuyAiCredit => "buy_ai_credit",
12501244 Op::ListInvoices => "list_invoices",
12511245 Op::GetBillingDetails => "get_billing_details",
12521246 Op::RequestReviewers => "request_reviewers",
17461740 "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."
17471741 }
17481742 Op::GetUsage => {
1749− "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, counted in any range that reaches into the current month, and `pending_charged_micros` is what of it will be charged then, after the discount and what g1t covers; `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`, the split `by_project`, and a `note` where the quantity needs one: the agent rate's meters, `agent_rate` and `agent_rate_own` (on the workspace's own model key), count weighted tokens and name the weights), `projects` (every repository with usage in the range), `models` (the agent's input, output, cache-read and cache-write tokens by model, most first), `by_agent` and `by_person` (the agent product at price by the agent that did the work, by handle, and by who asked, by username; `g1t` is g1t's own runs on repositories, an empty key is work attributed to no one; each sums to the agent product), 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."
1743+ "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 (sandboxes, deployments, git_storage, packages, security, search, other) 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, counted in any range that reaches into the current month, and `pending_charged_micros` is what of it will be charged then, after the discount and what g1t covers; `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 credit left now: `credit_micros`, and `ai_credit_micros` for AI credit bought earlier, which still pays toward what is owed. Usage on a meter g1t no longer has, such as agent runs and model requests from before they were retired, is kept on one meter in a last product, `other`, shown only when it has something, so nothing charged is left out. `models`, `by_agent` and `by_person` are always empty, and each product's `features` too. 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."
17501744 }
17511745 Op::GetBudget => {
17521746 "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."
17541748 Op::SetBudget => {
17551749 "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."
17561750 }
1757− Op::GetAiCredit => {
1758− "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."
1759− }
1760− Op::BuyAiCredit => {
1761− "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."
1762− }
17631751 Op::ListInvoices => {
1764− "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, 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; `amount_micros` is the usage, and the card processing fee (`fee_micros`) and tax (`tax_micros`) are on top. Prices exclude tax: Stripe adds it where it applies. `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."
1752+ "A workspace's invoices, newest first. `invoices` is every invoice billed to it (the plan, usage and any AI credit bought earlier), 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; `amount_micros` is the usage, and the card processing fee (`fee_micros`) and tax (`tax_micros`) are on top. Prices exclude tax: Stripe adds it where it applies. `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."
17651753 }
17661754 Op::GetBillingDetails => {
17671755 "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. Tax is worked out from the address: `tax_location` says whether it is enough for that (a country, and in the US a ZIP code), `tax_address_needed_at` is set while g1t is holding a charge for want of one, `tax_id_status` is Stripe's check of the tax ID (`pending`, `verified`, `unverified` or `unavailable`), and `tax_exempt` is `none`, `exempt` or `reverse`. Members of the workspace only."
30863074 }),
30873075 &["workspace"],
30883076 ),
3089− Op::GetBudget | Op::GetAiCredit | Op::ListInvoices | Op::GetBillingDetails => {
3077+ Op::GetBudget | Op::ListInvoices | Op::GetBillingDetails => {
30903078 object(json!({ "workspace": workspace_schema() }), &["workspace"])
30913079 }
30923080 Op::SetBudget => object(
31093097 },
31103098 }),
31113099 &["workspace"],
3112− ),
3113− Op::BuyAiCredit => object(
3114− json!({
3115− "workspace": workspace_schema(),
3116− "amount_cents": {
3117− "type": "integer",
3118− "minimum": 1000,
3119− "maximum": 100000,
3120− "multipleOf": 100,
3121− "description": "The credit to buy, in cents, in whole dollars: 5000 is $50.",
3122− },
3123− }),
3124− &["workspace", "amount_cents"],
31253100 ),
31263101 Op::ListUserTeams => object(
31273102 json!({ "workspace": workspace_schema(), "username": username_schema() }),
33653340 | Op::GetUsage
33663341 | Op::GetBudget
33673342 | Op::SetBudget
3368− | Op::GetAiCredit
3369− | Op::BuyAiCredit
33703343 | Op::ListInvoices
33713344 | Op::GetBillingDetails
33723345 )
52785251 Op::GetUsage
52795252 | Op::GetBudget
52805253 | Op::SetBudget
5281− | Op::GetAiCredit
5282− | Op::BuyAiCredit
52835254 | Op::ListInvoices
52845255 | Op::GetBillingDetails => crate::billing::run(self, services, viewer, input).await,
52855256 Op::ListUserTeams => {
+45−166
57675767 "days": [
57685768 {
57695769 "day": "2026-10-06",
5770− "product": "agent",
5770+ "product": "other",
57715771 "micros": 12400000
57725772 },
57735773 {
57775777 },
57785778 {
57795779 "day": "2026-10-07",
5780− "product": "agent",
5780+ "product": "other",
57815781 "micros": 9800000
57825782 }
57835783 ],
57845784 "products": [
5785− {
5786− "key": "agent",
5787− "label": "Agent",
5788− "micros": 41000000,
5789− "meters": [
5790− {
5791− "key": "agent_models",
5792− "label": "Models",
5793− "product": "agent",
5794− "unit": "tokens",
5795− "quantity": 9120000,
5796− "micros": 41000000,
5797− "pending_micros": 0,
5798− "daily": [
5799− 3100000,
5800− 5200000,
5801− 4400000,
5802− 6000000,
5803− 0,
5804− 12400000,
5805− 9800000
5806− ],
5807− "allowance": null,
5808− "by_project": [
5809− {
5810− "project": "flagon-io/g1t",
5811− "micros": 36000000,
5812− "quantity": 8010000
5813− },
5814− {
5815− "project": "flagon-io/hello",
5816− "micros": 5000000,
5817− "quantity": 1110000
5818− }
5819− ]
5820− }
5821− ],
5822− "features": [
5823− {
5824− "key": "runs",
5825− "label": "Runs",
5826− "micros": 33000000,
5827− "count": 14
5828− },
5829− {
5830− "key": "reviews",
5831− "label": "Reviews",
5832− "micros": 8000000,
5833− "count": 9
5834− }
5835− ]
5836− },
58375785 {
58385786 "key": "sandboxes",
58395787 "label": "Sandboxes",
58715819 }
58725820 ],
58735821 "features": []
5822+ },
5823+ {
5824+ "key": "other",
5825+ "label": "Other",
5826+ "micros": 41000000,
5827+ "meters": [
5828+ {
5829+ "key": "other",
5830+ "label": "Other",
5831+ "product": "other",
5832+ "unit": "entries",
5833+ "quantity": 23,
5834+ "micros": 41000000,
5835+ "pending_micros": 0,
5836+ "daily": [
5837+ 3100000,
5838+ 5200000,
5839+ 4400000,
5840+ 6000000,
5841+ 0,
5842+ 12400000,
5843+ 9800000
5844+ ],
5845+ "allowance": null,
5846+ "by_project": [
5847+ {
5848+ "project": "flagon-io/g1t",
5849+ "micros": 36000000,
5850+ "quantity": 14
5851+ },
5852+ {
5853+ "project": "flagon-io/hello",
5854+ "micros": 5000000,
5855+ "quantity": 9
5856+ }
5857+ ]
5858+ }
5859+ ],
5860+ "features": []
58745861 }
58755862 ],
58765863 "projects": [
58775864 "flagon-io/g1t",
58785865 "flagon-io/hello"
58795866 ],
5880− "models": [
5881− {
5882− "model": "claude-sonnet-5-5",
5883− "input": 310000,
5884− "output": 420000,
5885− "cache_read": 7800000,
5886− "cache_write": 590000
5887− }
5888− ],
5889− "by_agent": [
5890− {
5891− "key": "g1t",
5892− "label": "@g1t",
5893− "micros": 21400000,
5894− "count": 14
5895− },
5896− {
5897− "key": "mike",
5898− "label": "@mike",
5899− "micros": 800000,
5900− "count": 6
5901− }
5902− ],
5903− "by_person": [
5904− {
5905− "key": "chase",
5906− "label": "@chase",
5907− "micros": 21900000,
5908− "count": 19
5909− },
5910− {
5911− "key": "",
5912− "label": "No one asked",
5913− "micros": 300000,
5914− "count": 1
5915− }
5916− ],
5867+ "models": [],
5868+ "by_agent": [],
5869+ "by_person": [],
59175870 "included": {
59185871 "used": 20,
59195872 "of": 20,
59375890 }
59385891 ]
59395892 },
5940− "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`."
5893+ "notes": "Every product family with meters is listed, in order, even with nothing used; this example shows two. `other` comes last, and only when it has something: usage on meters g1t no longer has, such as agent runs from before they were retired. `daily` has one amount for each day of the range, oldest first. `projects` and `products` take several values separated by commas: `?products=sandboxes,deployments`. A range of more than 400 days, or one that ends before it starts, is refused with `invalid`."
59415894 },
59425895 "get_budget": {
59435896 "params": {
59945947 "message": null
59955948 },
59965949 "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."
5997− },
5998− "get_ai_credit": {
5999− "params": {
6000− "workspace": "flagon-io"
6001− },
6002− "response": {
6003− "balance_micros": 62000000,
6004− "purchased_micros": 50000000,
6005− "given_micros": 12000000,
6006− "grants": [
6007− {
6008− "id": "crd_01kkr2m4c8f1t7qh3d6n9w5p0x",
6009− "workspace": "flagon-io",
6010− "kind": "purchase",
6011− "amount_micros": 50000000,
6012− "used_micros": 0,
6013− "left_micros": 50000000,
6014− "note": "AI credit bought",
6015− "refund_for": null,
6016− "refund_day": null,
6017− "expires_at": "2027-10-02T00:00:00.000Z",
6018− "created_by": "ana",
6019− "created_at": "2026-10-02T16:20:00.000Z",
6020− "state": "open",
6021− "closed_at": null,
6022− "closed_note": null,
6023− "closed_by": null
6024− }
6025− ],
6026− "free_via_discount": false,
6027− "postpaid": false,
6028− "blocked": false,
6029− "can_buy": true,
6030− "presets_cents": [
6031− 2500,
6032− 5000,
6033− 10000,
6034− 25000
6035− ],
6036− "min_cents": 1000,
6037− "max_cents": 100000,
6038− "card_fee": {
6039− "on": true,
6040− "percent_micros": 29000,
6041− "fixed_cents": 30
6042− },
6043− "reload": {
6044− "enabled": false,
6045− "threshold_micros": 10000000,
6046− "target_micros": 50000000,
6047− "monthly_max_micros": 200000000,
6048− "reloaded_micros": 0,
6049− "failed_at": null,
6050− "error": null
6051− },
6052− "agent_rate_micros": 3.6,
6053− "model_markup_percent": 10,
6054− "gateway_markup_percent": 5,
6055− "upgrade_credit_micros": 10000000,
6056− "expires_days": 365
6057− },
6058− "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."
6059− },
6060− "buy_ai_credit": {
6061− "params": {
6062− "workspace": "flagon-io"
6063− },
6064− "request": {
6065− "amount_cents": 5000
6066− },
6067− "response": {
6068− "url": "https://checkout.example.com/c/pay/cs_test_a1b2c3"
6069− },
6070− "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`."
60715950 },
60725951 "list_invoices": {
60735952 "params": {
+1−5
276276 &[],
277277 ),
278278 route("GET", "/workspaces/:workspace/members/:username/teams", Op::ListUserTeams, &[]),
279− // A workspace's billing: usage, budget, AI credit and invoices.
279+ // A workspace's billing: usage, budget and invoices.
280280 route(
281281 "GET",
282282 "/workspaces/:workspace/usage",
285285 ),
286286 route("GET", "/workspaces/:workspace/budget", Op::GetBudget, &[]),
287287 route("PUT", "/workspaces/:workspace/budget", Op::SetBudget, &[]),
288− route("GET", "/workspaces/:workspace/ai_credit", Op::GetAiCredit, &[]),
289− route("POST", "/workspaces/:workspace/ai_credit/checkout", Op::BuyAiCredit, &[]),
290288 route("GET", "/workspaces/:workspace/invoices", Op::ListInvoices, &[]),
291289 route("GET", "/workspaces/:workspace/billing_details", Op::GetBillingDetails, &[]),
292290 // The AI Gateway's log of a workspace's requests.
14441442 assert_eq!(input, json!({ "alerts": [50], "workspace": "acme" }));
14451443 let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op;
14461444 assert_eq!(op("GET", "/workspaces/acme/budget"), Op::GetBudget);
1447− assert_eq!(op("GET", "/workspaces/acme/ai_credit"), Op::GetAiCredit);
1448− assert_eq!(op("POST", "/workspaces/acme/ai_credit/checkout"), Op::BuyAiCredit);
14491445 assert_eq!(op("GET", "/workspaces/acme/invoices"), Op::ListInvoices);
14501446 assert_eq!(op("GET", "/workspaces/acme/billing_details"), Op::GetBillingDetails);
14511447 }
+3−3
430430 Tool {
431431 name: "billing",
432432 title: "Billing",
433− 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 a run's token.",
433+ 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) and its invoices. Amounts are whole millionths of a dollar (`_micros`), or cents (`_cents`) where named. Members read it; changing the budget is for owners, as people, and never for a run's token.",
434434 default_action: Some("usage"),
435435 actions: &[
436436 a("usage", Op::GetUsage, "Usage over a range of days, by product, meter, project and day, and what paid for it"),
437437 a("budget", Op::GetBudget, "The monthly spend limit, what was spent, alerts and whether usage pauses at the limit"),
438438 a("set_budget", Op::SetBudget, "Change the spend limit, alerts, pausing or the alert webhook"),
439− a("ai_credit", Op::GetAiCredit, "AI credit left, its grants, auto-reload and how to buy more"),
440− a("buy_ai_credit", Op::BuyAiCredit, "A payment page to buy AI credit, for a person to open"),
441439 a("invoices", Op::ListInvoices, "Every invoice, the itemised usage invoices, and the next one so far"),
442440 a("billing_details", Op::GetBillingDetails, "Who invoices are made out to and the payment method on file"),
443441 ],
861859 "set_plugin_enabled",
862860 "uninstall_plugin",
863861 "update_plugin",
862+ "get_ai_credit",
863+ "buy_ai_credit",
864864 "list_plugin_agents",
865865 "list_connectors",
866866 "get_connector",
+1−1
777777 | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub |
778778 | Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` |
779779 | Deleting a workspace, and giving it to another owner | A workspace's General settings, and its Members page |
780−| Payment methods: the billing portal, adding a card, subscribing and buying AI credit | A workspace's Billing |
780+| Payment methods: the billing portal, adding a card and subscribing | A workspace's Billing |
781781
782782 ### Chat and notifications without the website
783783
+1−1
229229 | [Accounts](/reference/api/accounts/whoami/) | Signing in from a tool, and who a token acts as. Accounts are made and [deleted](/guides/authentication/#deleting-your-account) only in a browser: there is no route for either. |
230230 | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. |
231231 | [Notifications](/reference/api/notifications/list-notifications/) | Your notifications: their threads, why you were told of each, marking them read, done, saved or snoozed, and what you subscribe to and watch. See [notifications](/guides/notifications/). |
232−| [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/). |
232+| [Billing](/reference/api/billing/get-usage/) | A workspace's usage by product, project and day, its budget and its invoices. See [usage and billing](/guides/usage-and-billing/). |
233233 | [Invites](/reference/api/invites/list-invites/) | Your invites while g1t is invite-only, and inviting people into a workspace by email. |
234234 | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. |
235235 | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. |
+7−10
686686
687687 ## `billing`
688688
689−A workspace's billing: its usage, its budget, its AI credit and its
690−invoices. `usage` is the default action. Amounts are whole millionths of a
691−dollar (`_micros`), or cents where a field says `_cents`. Members of the
692−workspace read it, a workspace's own token included. Changing the budget
693−and buying AI credit are for its owners, as people: signed in or with a
694−personal access token. A workspace's token never changes
695−billing, whatever their scopes, and no preset but full access includes
696−`billing:write`. See [usage and billing](/guides/usage-and-billing/).
689+A workspace's billing: its usage, its budget and its invoices. `usage` is
690+the default action. Amounts are whole millionths of a dollar (`_micros`),
691+or cents where a field says `_cents`. Members of the workspace read it, a
692+workspace's own token included. Changing the budget is for its owners, as
693+people: signed in or with a personal access token. A workspace's token
694+never changes billing, whatever its scopes, and no preset but full access
695+includes `billing:write`. See [usage and billing](/guides/usage-and-billing/).
697696
698697 | Action | What it does | Required | Scope |
699698 | --- | --- | --- | --- |
700699 | [`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` |
701700 | [`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` |
702701 | [`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` |
703−| [`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` |
704−| [`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` |
705702 | [`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` |
706703 | [`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` |
707704
+0−0

Binary or large file; its contents are not shown.

+1−1
758758 #[test]
759759 fn a_discount_below_cost_plus_the_margin_is_counted_as_given() {
760760 // $1 of model cost at 20%: $1.20 is the floor of what a sale is worth.
761− let base = crate::charge_micros(1.0, 20);
761+ let base = crate::margin_on(1_000_000, 20);
762762 assert_eq!(base, 1_200_000);
763763 // Standard: all of it sold, nothing given.
764764 assert_eq!(terms(TermsKind::Standard, 0).discounted(base), (1_200_000, 0));
+0−1218
A large diff (1,218 lines) is not shown until you ask for it.
+0−42
1−//! Whose work a usage line is: the agent that did it, by handle, and the
2−//! person who asked, by username. Both are written on the run
3−//! (`start_run`) and copied onto every ledger line it makes, and on an
4−//! agent's sandbox time (`record_sandbox`), so Spend's "by agent" and "by
5−//! person" are read from the one ledger that is charged
6−//! (`report::attribution`), never from a second count.
7−
8−/// A handle or username as the ledger stores it: lowercase, without a
9−/// leading `@`; none when empty.
10−pub(crate) fn handle(given: Option<&str>) -> Option<String> {
11− let name = given?.trim().trim_start_matches('@').to_lowercase();
12− (!name.is_empty()).then_some(name)
13−}
14−
15−/// The agent a run is attributed to: the one the caller names; else the
16−/// one a run billed under `<workspace>/@<handle>` names (the agents
17−/// service's replies and sessions; no repository has an `@` in its name);
18−/// else g1t's own agent at work on a repository, which all shows as @g1t.
19−pub(crate) fn agent_of(given: Option<&str>, repo_name: &str) -> Option<String> {
20− handle(given).or_else(|| repo_name.strip_prefix('@').and_then(|h| handle(Some(h)))).or_else(|| Some("g1t".to_owned()))
21−}
22−
23−#[cfg(test)]
24−mod tests {
25− use super::*;
26−
27− #[test]
28− fn a_run_is_the_agent_named_or_the_one_it_is_billed_under_or_g1t() {
29− assert_eq!(agent_of(Some("Mike"), "@mike").as_deref(), Some("mike"));
30− assert_eq!(agent_of(None, "@margo").as_deref(), Some("margo"));
31− assert_eq!(agent_of(Some(" "), "@margo").as_deref(), Some("margo"));
32− assert_eq!(agent_of(None, "g1t").as_deref(), Some("g1t"));
33− assert_eq!(agent_of(Some("@g1t"), "api").as_deref(), Some("g1t"));
34− }
35−
36− #[test]
37− fn who_asked_is_a_username_or_no_one() {
38− assert_eq!(handle(Some("@Chase")).as_deref(), Some("chase"));
39− assert_eq!(handle(Some("")), None);
40− assert_eq!(handle(None), None);
41− }
42−}
+0−1051
A large diff (1,051 lines) is not shown until you ask for it.
+0−333
1−//! The model catalogue: discovery's diff, prices, typical runs, defaults
2−//! and their fallbacks (`catalogue.rs`).
3−
4−use super::*;
5−
6−fn model(id: &str, name: &str, tier: &str, status: &str) -> CatalogueModel {
7− let prices = known_prices(id).unwrap_or(ModelPrices { input_micros: 1_000_000, output_micros: 5_000_000, ..ModelPrices::default() });
8− let base = GatewayModel {
9− model: id.into(),
10− name: name.into(),
11− provider: "anthropic".into(),
12− kind: "chat".into(),
13− input_micros: 0,
14− output_micros: 0,
15− cache_read_micros: 0,
16− cache_write_micros: 0,
17− cache_write_1h_micros: 0,
18− threshold: 0,
19− over_input_micros: 0,
20− over_output_micros: 0,
21− over_cache_read_micros: 0,
22− over_cache_write_micros: 0,
23− over_cache_write_1h_micros: 0,
24− };
25− CatalogueModel {
26− prices: with_prices(base, &prices),
27− aliases: vec![],
28− family: String::new(),
29− tier_hint: tier.into(),
30− context_window: 0,
31− max_output: 0,
32− capabilities: vec!["effort".into()],
33− dimensions: 0,
34− status: status.into(),
35− priced: true,
36− source: "staff".into(),
37− first_seen_at: None,
38− last_seen_at: None,
39− missing_since: None,
40− approved_by: None,
41− approved_at: None,
42− note: String::new(),
43− typical_run_micros: 0,
44− }
45−}
46−
47−fn listed(id: &str) -> ProviderModel {
48− ProviderModel { id: id.into(), name: String::new(), kind: "chat".into(), ..ProviderModel::default() }
49−}
50−
51−fn catalogue() -> Vec<CatalogueModel> {
52− let mut haiku_4_5 = model("claude-haiku-4-5", "Claude Haiku 4.5", "small", "available");
53− haiku_4_5.aliases = vec!["claude-haiku-4-5-20251001".into()];
54− vec![
55− model("claude-haiku-5-5", "Claude Haiku 5.5", "small", "available"),
56− haiku_4_5,
57− model("claude-haiku-4-5-20251001", "Claude Haiku 4.5", "small", "available"),
58− model("claude-sonnet-5-5", "Claude Sonnet 5.5", "large", "available"),
59− model("claude-opus-5-5", "Claude Opus 5.5", "frontier", "available"),
60− ]
61−}
62−
63−#[test]
64−fn a_model_never_seen_is_added_and_one_no_longer_listed_is_gone() {
65− let found = diff(
66− "anthropic",
67− &catalogue(),
68− &[listed("claude-haiku-6"), listed("claude-haiku-5-5"), listed("claude-haiku-4-5-20251001"), listed("claude-sonnet-5-5")],
69− );
70− assert_eq!(found.added.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["claude-haiku-6"]);
71− // Opus 5.5 was not listed: gone. Haiku 4.5 is listed by its dated id, which it has as an alias.
72− assert_eq!(found.gone, ["claude-opus-5-5"]);
73− let seen: Vec<&str> = found.seen.iter().map(|(m, _)| m.as_str()).collect();
74− assert_eq!(seen, ["claude-haiku-5-5", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-sonnet-5-5"]);
75− assert!(found.restored.is_empty());
76−}
77−
78−#[test]
79−fn a_dated_id_of_a_model_it_has_is_that_model_and_becomes_an_alias() {
80− let found = diff("anthropic", &catalogue(), &[listed("claude-sonnet-5-5-20261001")]);
81− assert!(found.added.is_empty());
82− assert_eq!(found.seen, [("claude-sonnet-5-5".to_owned(), Some("claude-sonnet-5-5-20261001".to_owned()))]);
83− assert!(is_dated("claude-sonnet-5-5-20261001", "claude-sonnet-5-5"));
84− assert!(!is_dated("claude-sonnet-5-5-preview", "claude-sonnet-5-5"));
85− assert!(!is_dated("claude-sonnet-5", "claude-sonnet-5-5"));
86−}
87−
88−#[test]
89−fn an_empty_or_failed_list_finds_nothing_gone() {
90− let found = diff("anthropic", &catalogue(), &[]);
91− assert_eq!(found, Diff::default());
92− // Another provider's models are never its to judge.
93− let found = diff("workers-ai", &catalogue(), &[listed("@cf/meta/llama-5")]);
94− assert!(found.gone.is_empty());
95− assert_eq!(found.added.len(), 1);
96−}
97−
98−#[test]
99−fn a_deprecated_model_listed_again_is_restored_and_a_retired_one_is_left_alone() {
100− let mut models = catalogue();
101− models[3].status = "deprecated".into();
102− models[3].missing_since = Some("2026-10-01T04:17:00Z".into());
103− models[4].status = "retired".into();
104− let found = diff("anthropic", &models, &[listed("claude-sonnet-5-5"), listed("claude-haiku-5-5")]);
105− assert_eq!(found.restored, ["claude-sonnet-5-5"]);
106− // Retired Opus is not listed and stays retired; Haiku 4.5 is gone.
107− assert_eq!(found.gone, ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]);
108−}
109−
110−#[test]
111−fn only_chat_and_embeddings_models_are_added() {
112− let mut speech = listed("@cf/openai/whisper");
113− speech.kind = "other".into();
114− let mut embed = listed("@cf/baai/bge-large-en-v1.5");
115− embed.kind = "embeddings".into();
116− let found = diff("workers-ai", &[], &[speech, embed, listed("@cf/meta/llama-5")]);
117− assert_eq!(found.added.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["@cf/baai/bge-large-en-v1.5", "@cf/meta/llama-5"]);
118−}
119−
120−#[test]
121−fn a_new_model_is_priced_from_the_table_or_its_listing_or_not_at_all() {
122− // A dated Opus 5.5 is in the table.
123− let (row, facts) = new_row("anthropic", &listed("claude-opus-5-5-20261101"), 120);
124− assert!(facts.priced);
125− assert_eq!((row.input_micros, row.output_micros, row.cache_read_micros), (4_000_000, 20_000_000, 200_000));
126− assert_eq!((facts.family.as_str(), facts.tier_hint.as_str(), facts.position), ("opus", "frontier", 120));
127− // A Haiku g1t has no price for: unpriced, a fast model by its name.
128− let (row, facts) = new_row("anthropic", &listed("claude-haiku-6"), 0);
129− assert!(!facts.priced);
130− assert_eq!((row.input_micros, facts.tier_hint.as_str()), (0, "small"));
131− // Workers AI says its own price; cached tokens cost what input does.
132− let mut open = listed("@cf/meta/llama-5-70b");
133− open.price = Some(g1t_contracts::billing::ListedPrice { input_micros: 290_000, output_micros: 2_250_000 });
134− let (row, facts) = new_row("workers-ai", &open, 0);
135− assert!(facts.priced);
136− assert_eq!((row.input_micros, row.output_micros, row.cache_read_micros, row.name.as_str()), (290_000, 2_250_000, 290_000, "llama-5-70b"));
137− assert_eq!((facts.family.as_str(), facts.tier_hint.as_str()), ("meta", ""));
138−}
139−
140−#[test]
141−fn haiku_5_5_is_known_with_its_long_prompt_prices() {
142− let prices = known_prices("claude-haiku-5-5").unwrap();
143− assert_eq!((prices.input_micros, prices.output_micros, prices.cache_read_micros), (100_000, 500_000, 10_000));
144− assert_eq!((prices.threshold, prices.over_input_micros, prices.over_output_micros), (100_000, 500_000, 2_500_000));
145− assert!(known_prices("claude-haiku-6").is_none());
146− // `claude-opus-5` is not `claude-opus-5-5`.
147− assert_eq!(known_prices("claude-opus-5").unwrap().input_micros, 5_000_000);
148−}
149−
150−#[test]
151−fn a_typical_run_is_priced_request_by_request() {
152− let models = catalogue();
153− let haiku = &models[0].prices;
154− let sonnet = &models[3].prices;
155− let opus = &models[4].prices;
156− // 40 requests of 2k in, 1.5k out, 45k cache reads and 4k cache writes.
157− assert_eq!(typical_run(sonnet), 1_340_000);
158− assert_eq!(typical_run(opus), 2_680_000);
159− // Each prompt is 51k, under Haiku 5.5's 100k threshold: its lower prices.
160− assert_eq!(typical_run(haiku), 76_000);
161− let mut embeddings = haiku.clone();
162− embeddings.kind = "embeddings".into();
163− assert_eq!(typical_run(&embeddings), 0);
164−}
165−
166−#[test]
167−fn a_default_that_can_be_used_is_used() {
168− let resolved = resolve("tier_small", "claude-haiku-5-5", &catalogue());
169− assert_eq!(resolved.model.unwrap().model, "claude-haiku-5-5");
170− assert!(resolved.note.is_none());
171−}
172−
173−#[test]
174−fn a_retired_default_falls_back_to_the_next_model_of_its_tier_and_says_so() {
175− let mut models = catalogue();
176− models[0].status = "retired".into();
177− let resolved = resolve("tier_small", "claude-haiku-5-5", &models);
178− assert_eq!(resolved.model.unwrap().model, "claude-haiku-4-5");
179− assert_eq!(resolved.note.as_deref(), Some("Claude Haiku 5.5 is retired; using Claude Haiku 4.5 instead."));
180− // Deprecated and unpriced models are never handed out either.
181− models[1].status = "deprecated".into();
182− models[2].priced = false;
183− let resolved = resolve("background", "claude-haiku-5-5", &models);
184− assert!(resolved.model.is_none());
185− assert_eq!(resolved.note.as_deref(), Some("Claude Haiku 5.5 is retired, and no other model suits it."));
186− // The gateway's first model takes any Claude.
187− let resolved = resolve("gateway_first", "claude-haiku-5-5", &models);
188− assert_eq!(resolved.model.unwrap().model, "claude-sonnet-5-5");
189−}
190−
191−#[test]
192−fn a_new_model_is_never_a_default_until_approved() {
193− let mut models = catalogue();
194− models.push(model("claude-haiku-6", "Claude Haiku 6", "small", "new"));
195− let resolved = resolve("tier_small", "claude-haiku-6", &models);
196− assert_eq!(resolved.model.unwrap().model, "claude-haiku-5-5");
197− assert_eq!(resolved.note.as_deref(), Some("Claude Haiku 6 is new; using Claude Haiku 5.5 instead."));
198− let set = AdminSetModelDefaultArgs { purpose: "tier_small".into(), model: Some("claude-haiku-6".into()), reason: "cheaper".into(), ..Default::default() };
199− assert_eq!(check_default(&set, &models).unwrap_err(), "Claude Haiku 6 is new: approve it first.");
200−}
201−
202−#[test]
203−fn defaults_are_checked_before_they_are_kept() {
204− let models = catalogue();
205− let mut open = model("@cf/openai/gpt-oss-120b", "gpt-oss-120b", "", "available");
206− open.prices.provider = "workers-ai".into();
207− let models = [models, vec![open]].concat();
208− let args = |purpose: &str, model: Option<&str>, tier: Option<&str>, effort: Option<&str>, reason: &str| AdminSetModelDefaultArgs {
209− purpose: purpose.into(),
210− model: model.map(Into::into),
211− tier: tier.map(Into::into),
212− effort: effort.map(Into::into),
213− reason: reason.into(),
214− by: "staff@g1t.sh".into(),
215− };
216− assert_eq!(check_default(&args("tier_large", Some("claude-opus-5-5"), None, None, "try it"), &models), Ok((Some("claude-opus-5-5".into()), None, None)));
217− assert_eq!(check_default(&args("tier_large", Some("claude-opus-5-5"), None, None, " "), &models).unwrap_err(), "Say why, for whoever looks next.");
218− assert!(check_default(&args("tier_large", Some("@cf/openai/gpt-oss-120b"), None, None, "x"), &models).unwrap_err().contains("not a Claude chat model"));
219− assert_eq!(check_default(&args("tier_large", Some("claude-nope"), None, None, "x"), &models).unwrap_err(), "claude-nope is not in the catalogue.");
220− assert_eq!(
221− check_default(&args("job_plan", None, Some("small"), Some("xhigh"), "x"), &models),
222− Ok((None, Some("small".into()), Some("xhigh".into())))
223− );
224− assert_eq!(check_default(&args("job_update", None, Some("small"), Some(""), "x"), &models), Ok((None, Some("small".into()), None)));
225− assert!(check_default(&args("job_plan", None, Some("change"), None, "x"), &models).is_err());
226− assert!(check_default(&args("job_review", None, Some("change"), None, "x"), &models).is_ok());
227− assert!(check_default(&args("job_plan", None, Some("small"), Some("huge"), "x"), &models).is_err());
228− assert!(check_default(&args("job_dance", None, Some("small"), None, "x"), &models).is_err());
229−}
230−
231−#[test]
232−fn the_runtime_view_has_each_purpose_and_job() {
233− let row = |purpose: &str, model: Option<&str>, tier: Option<&str>, effort: Option<&str>| ModelDefault {
234− purpose: purpose.into(),
235− model: model.map(Into::into),
236− tier: tier.map(Into::into),
237− effort: effort.map(Into::into),
238− updated_at: "2026-10-08T00:00:00Z".into(),
239− updated_by: "migration".into(),
240− reason: String::new(),
241− };
242− let defaults = vec![
243− row("tier_small", Some("claude-haiku-5-5"), None, None),
244− row("gateway_first", Some("claude-haiku-5-5"), None, None),
245− row("job_plan", None, Some("small"), Some("high")),
246− row("job_review", None, Some("change"), None),
247− row("job_answer", None, Some("nonsense"), None),
248− ];
249− let view = defaults_view(&defaults, &catalogue());
250− assert_eq!(view.models.iter().map(|m| m.purpose.as_str()).collect::<Vec<_>>(), ["tier_small", "gateway_first"]);
251− assert_eq!(
252− view.jobs,
253− [
254− JobDefault { kind: "review".into(), tier: "change".into(), effort: None },
255− JobDefault { kind: "plan".into(), tier: "small".into(), effort: Some("high".into()) },
256− ]
257− );
258−}
259−
260−#[test]
261−fn the_gateway_lists_staffs_first_claude_first() {
262− let mut models: Vec<GatewayModel> = catalogue().into_iter().map(|m| m.prices).collect();
263− put_first(&mut models, Some("claude-sonnet-5-5"));
264− assert_eq!(models[0].model, "claude-sonnet-5-5");
265− assert_eq!(models[1].model, "claude-haiku-5-5");
266− put_first(&mut models, Some("claude-nope"));
267− assert_eq!(models[0].model, "claude-sonnet-5-5");
268−}
269−
270−#[test]
271−fn prices_staff_confirm_must_make_sense() {
272− let good = known_prices("claude-haiku-5-5").unwrap();
273− assert!(check_prices("chat", &good).is_ok());
274− assert!(check_prices("chat", &ModelPrices { output_micros: 0, ..good.clone() }).is_err());
275− assert!(check_prices("chat", &ModelPrices { input_micros: -1, ..good.clone() }).is_err());
276− assert!(check_prices("chat", &ModelPrices { threshold: 0, ..good.clone() }).is_err());
277− assert!(check_prices("chat", &ModelPrices { input_micros: 2_000_000_000, ..good.clone() }).is_err());
278− assert!(check_prices("embeddings", &ModelPrices { input_micros: 12_000, ..ModelPrices::default() }).is_ok());
279−}
280−
281−#[test]
282−fn staff_are_told_what_a_check_found() {
283− let result = DiscoveryResult {
284− provider: "anthropic".into(),
285− added: vec!["claude-haiku-6".into()],
286− deprecated: vec!["claude-haiku-4-5".into()],
287− ..DiscoveryResult::default()
288− };
289− let (subject, lines) = discovery_email(&result);
290− assert_eq!(subject, "g1t: new Anthropic models: claude-haiku-6");
291− assert!(lines[0].contains("until you approve it with its prices in sudo"));
292− assert!(lines[1].contains("no longer lists claude-haiku-4-5"));
293− assert_eq!(discovery_detail(&result), "anthropic: new: claude-haiku-6; no longer listed: claude-haiku-4-5");
294−}
295−
296−#[test]
297−fn dollars_read_plainly() {
298− assert_eq!(dollars(100_000), "0.10");
299− assert_eq!(dollars(2_000_000), "2");
300− assert_eq!(dollars(12_500_000), "12.50");
301− assert_eq!(dollars(10_000), "0.01");
302− assert_eq!(dollars(125_000), "0.125");
303−}
304−
305−#[test]
306−fn the_migration_seeds_every_purpose_as_routing_had_it() {
307− let sql = include_str!("../migrations/0048_model_catalogue.sql");
308− for purpose in MODEL_PURPOSES {
309− assert!(sql.contains(&format!("('{purpose}', 'claude-")), "no seed for {purpose}");
310− }
311− for kind in JOB_KINDS {
312− assert!(sql.contains(&format!("('job_{kind}', NULL, '")), "no seed for job_{kind}");
313− }
314− // As AGENT_ROUTING (services/runner/wrangler.jsonc) had them.
315− let runner = include_str!("../../runner/wrangler.jsonc");
316− for (purpose, model) in [("tier_small", "claude-haiku-5-5"), ("tier_large", "claude-sonnet-5-5"), ("tier_frontier", "claude-opus-5-5")] {
317− assert!(sql.contains(&format!("('{purpose}', '{model}'")));
318− assert!(runner.contains(&format!("\\\"model\\\":\\\"{model}\\\"")), "{model} is not in AGENT_ROUTING");
319− }
320− assert!(sql.contains("('job_plan', NULL, 'small', 'high'"));
321− assert!(sql.contains("('job_review', NULL, 'change', NULL"));
322− // Every column the discovery insert names exists.
323− let insert = "model, name, provider, kind, input_micros, output_micros, cache_read_micros, cache_write_micros, cache_write_1h_micros, threshold, over_input_micros, over_output_micros, over_cache_read_micros, over_cache_write_micros, over_cache_write_1h_micros, position, updated_at, family, tier_hint, context_window, max_output, capabilities, status, priced, source, first_seen_at, last_seen_at";
324− let all = [
325− include_str!("../migrations/0045_gateway.sql"),
326− include_str!("../migrations/0047_gateway_formats.sql"),
327− sql,
328− ]
329− .concat();
330− for column in insert.split(", ") {
331− assert!(all.contains(&format!("{column} ")) || all.contains(&format!("{column},")), "gateway_models has no {column}");
332− }
333−}
+6−259
319319 }
320320
321321 /// Where a cost line came from: AI Gateway's analytics, what it priced
322−/// g1t's own provider traffic at.
322+/// g1t's own provider traffic at. No longer read since g1t stopped running
323+/// agents; the lines already kept stay in the history.
323324 pub(crate) const SOURCE_GATEWAY: &str = "ai_gateway";
324−/// The product of AI Gateway's lines. Not `ai_gateway`, which is what a
325−/// billable-usage line from Cloudflare for AI Gateway itself would slug to.
326−pub(crate) const GATEWAY_PRODUCT: &str = "ai_gateway_requests";
327−/// Meter suffixes of a model's token lines (no cost; for the drift).
328−pub(crate) const GATEWAY_TOKENS: &str = "__tokens";
329−pub(crate) const GATEWAY_CACHE_READ: &str = "__cache_read_tokens";
330−pub(crate) const GATEWAY_CACHE_WRITE: &str = "__cache_write_tokens";
331325 /// The meter prefix of requests Cloudflare billed itself (unified billing),
332326 /// which are on Cloudflare's bill as well as here.
333327 pub(crate) const GATEWAY_WHOLESALE: &str = "wholesale__";
334328
335−/// What AI Gateway priced each day's requests at, per provider and model,
336−/// for g1t's gateway only: GraphQL `aiGatewayRequestsAdaptiveGroups`, with
337−/// `sum.cost` (dollars) and the tokens it priced; once for the requests
338−/// g1t's own provider keys paid (`wholesale: 0`) and once for those
339−/// Cloudflare billed itself (`wholesale: 1`). Field names checked against
340−/// the schema (`AccountAiGatewayRequestsAdaptiveGroupsSum`, `…Dimensions`
341−/// and `…Filter_InputObject`).
342−///
343−/// `wholesale` is a filter here, never a dimension: grouped by it,
344−/// Cloudflare answers no rows at all and no error. Seen 2026-10-08: with
345−/// `wholesale` in `dimensions` the query returned nothing for 562 requests
346−/// that cost $11.11, which it returns without it.
347−pub(crate) const GATEWAY_QUERY: &str = "query ($account: String!, $gateway: String!, $since: Date!, $until: Date!) {
348− viewer { accounts(filter: { accountTag: $account }) {
349− own: aiGatewayRequestsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until, gateway: $gateway, wholesale: 0 }) {
350− count
351− sum { cost tokensIn tokensOut cacheReadTokens cacheWriteTokens }
352− dimensions { date provider model }
353− }
354− wholesale: aiGatewayRequestsAdaptiveGroups(limit: 10000, filter: { date_geq: $since, date_leq: $until, gateway: $gateway, wholesale: 1 }) {
355− count
356− sum { cost tokensIn tokensOut cacheReadTokens cacheWriteTokens }
357− dimensions { date provider model }
358− }
359− } }
360−}";
361−
362−pub(crate) fn gateway_variables(account: &str, gateway: &str, since: &str, until: &str) -> Value {
363− json!({ "query": GATEWAY_QUERY, "variables": { "account": account, "gateway": gateway, "since": since, "until": until } })
364−}
365−
366−/// AI Gateway's analytics as lines: per day and model, the requests at
367−/// what the gateway priced them (meter `<provider>_<model>`), and their
368−/// tokens, cache reads and cache writes at no cost (`…__tokens`, …).
369−/// Errors when GraphQL does.
370−pub(crate) fn lines_from_gateway(body: &Value) -> std::result::Result<Vec<CostLine>, String> {
371− if let Some(errors) = body["errors"].as_array().filter(|e| !e.is_empty()) {
372− return Err(format!("AI Gateway analytics failed: {}", Value::Array(errors.clone())));
373− }
374− let account = &body["data"]["viewer"]["accounts"][0];
375− let groups = |alias: &str| account[alias].as_array().cloned().unwrap_or_default();
376− let mut lines = Vec::new();
377− for (wholesale, g) in groups("own").into_iter().map(|g| (false, g)).chain(groups("wholesale").into_iter().map(|g| (true, g))) {
378− let d = &g["dimensions"];
379− let Some(day) = d["date"].as_str().filter(|day| day.len() >= 10) else { continue };
380− let provider = d["provider"].as_str().unwrap_or("unknown");
381− let model = d["model"].as_str().unwrap_or("unknown");
382− let name = format!("{}{}", if wholesale { GATEWAY_WHOLESALE } else { "" }, slug(&format!("{provider} {model}")));
383− let sum = |key: &str| g["sum"][key].as_f64().unwrap_or(0.0);
384− let line = |meter: String, unit: &str, quantity: f64, cost_usd: f64| CostLine {
385− day: day[..10].to_owned(),
386− source: SOURCE_GATEWAY,
387− product: GATEWAY_PRODUCT.to_owned(),
388− meter,
389− unit: unit.to_owned(),
390− quantity,
391− cost_usd,
392− raw_name: format!("AI Gateway / {provider} / {model}{}", if wholesale { " (billed by Cloudflare)" } else { "" }),
393− ..CostLine::default()
394− };
395− lines.push(line(name.clone(), "requests", g["count"].as_f64().unwrap_or(0.0), sum("cost").max(0.0)));
396− lines.push(line(format!("{name}{GATEWAY_TOKENS}"), "tokens", sum("tokensIn") + sum("tokensOut"), 0.0));
397− for (suffix, key) in [(GATEWAY_CACHE_READ, "cacheReadTokens"), (GATEWAY_CACHE_WRITE, "cacheWriteTokens")] {
398− if sum(key) > 0.0 {
399− lines.push(line(format!("{name}{suffix}"), "tokens", sum(key), 0.0));
400− }
401− }
402− }
403− Ok(aggregate(lines))
404−}
405−
406−/// What the gateway's lines over a window say about whether its cost can
407−/// be taken as what the providers bill g1t.
408−#[derive(Clone, Debug, Default, PartialEq)]
409−pub(crate) struct GatewayCaveats {
410− /// Models the gateway put no price on although they used tokens.
411− pub unpriced: Vec<String>,
412− pub cache_read_tokens: f64,
413− pub cache_write_tokens: f64,
414− /// What Cloudflare billed itself (unified billing), in dollars: on its
415− /// bill too, so not a cost paid to a provider.
416− pub wholesale_usd: f64,
417− /// Runs settled with the gateway's figure short (see `keeper::settled_cost`).
418− pub short_runs: u32,
419− /// Requests the gateway logged, priced or not.
420− pub requests: f64,
421−}
422−
423−/// What the last read of AI Gateway's analytics found, so the models drift
424−/// can say why the gateway's side is empty rather than guess.
425−#[derive(Clone, Debug, Default, PartialEq)]
426−pub(crate) enum GatewayRead {
427− /// Not read: no `AI_GATEWAY_ID`, or no token to read it with.
428− #[default]
429− NotRead,
430− /// GraphQL refused it, or did not answer.
431− Failed(String),
432− /// It answered with requests.
433− Rows,
434− /// It answered with no rows. `visible`: whether the token it was read
435− /// with can see the gateway (`GET …/ai-gateway/gateways/{id}`: 403
436− /// without AI Gateway Read, 404 for an id that is not there), to tell
437− /// that from a gateway nothing went through; None when that could not
438− /// be told.
439− Empty { visible: Option<bool> },
440−}
441−
442−/// The caveats in AI Gateway's lines (any days, any order).
443−pub(crate) fn gateway_caveats(lines: &[(String, f64, f64)]) -> GatewayCaveats {
444− let mut out = GatewayCaveats::default();
445− let mut cost: BTreeMap<&str, f64> = BTreeMap::new();
446− let mut tokens: BTreeMap<&str, f64> = BTreeMap::new();
447− for (meter, quantity, cost_usd) in lines {
448− if let Some(model) = meter.strip_suffix(GATEWAY_TOKENS) {
449− *tokens.entry(model).or_default() += quantity;
450− } else if meter.ends_with(GATEWAY_CACHE_READ) {
451− out.cache_read_tokens += quantity;
452− } else if meter.ends_with(GATEWAY_CACHE_WRITE) {
453− out.cache_write_tokens += quantity;
454− } else {
455− out.requests += quantity;
456− *cost.entry(meter.as_str()).or_default() += cost_usd;
457− if meter.starts_with(GATEWAY_WHOLESALE) {
458− out.wholesale_usd += cost_usd;
459− }
460− }
461− }
462− for (model, used) in tokens {
463− if used > 0.0 && cost.get(model).copied().unwrap_or(0.0) <= 0.0 {
464− out.unpriced.push(model.to_owned());
465− }
466− }
467− out
468−}
469−
470329 /// The workspace a repository in the store belongs to: keys are
471330 /// `<workspace>--<repo>`; a pull request's working copy (`pulls--<id>`) is
472331 /// its repository's workspace's, from `owners` (repos' `pull_owners`), else
659518 /// Reads Cloudflare's bill into `cost_lines`: the billable usage over
660519 /// whole billing cycles (`cycle::bill_since`), every page of it, priced
661520 /// over each cycle (`cycle::price_lines`) and replacing what was kept
662− /// for those days; Artifacts' events and AI Gateway's analytics for the
663− /// days due (`window`). Returns those days, how many lines, and the
664− /// first day of the bill's read. None without a token. What could not be
665− /// read is added to `problems`, and what AI Gateway's analytics answered
666− /// to `gateway`.
521+ /// for those days; Artifacts' events for the days due (`window`).
522+ /// Returns those days, how many lines, and the first day of the bill's
523+ /// read. None without a token. What could not be read is added to
524+ /// `problems`.
667525 pub(crate) async fn read_cloudflare(
668526 &self,
669527 keeper: &Keeper,
670528 problems: &mut Vec<String>,
671− gateway: &mut GatewayRead,
672529 ) -> Result<Option<(String, String, u32, String)>> {
673530 if !keeper.can_read_bill() {
674531 return Ok(None);
726583 Err(error) => problems.push(format!("Artifacts events could not be read: {error}")),
727584 },
728585 Err(error) => problems.push(format!("Artifacts events could not be read: {error}")),
729− }
730− // What AI Gateway priced g1t's own provider traffic at, each day:
731− // the total the ledger's model cost is checked against (`margin`).
732− // Over its own window: until it has answered with a line, the 31
733− // days GraphQL keeps, whatever the bill's window is.
734− if !keeper.gateway().is_empty() {
735− let last = self
736− .db
737− .prepare("SELECT MAX(day) AS day FROM cost_lines WHERE source = ?")
738− .bind(&[SOURCE_GATEWAY.into()])?
739− .first::<Last>(None)
740− .await?
741− .and_then(|l| l.day);
742− let (from, to) = window(last.as_deref(), now_ms());
743− match keeper
744− .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &from, &to))
745− .await
746− .map_err(|e| e.to_string())
747− .and_then(|body| lines_from_gateway(&body))
748− {
749− Ok(lines) => {
750− // A day's models are read whole: one that is gone from a
751− // re-read day must not keep its old line.
752− self.db
753− .prepare("DELETE FROM cost_lines WHERE source = ?1 AND day >= ?2 AND day <= ?3")
754− .bind(&[SOURCE_GATEWAY.into(), from.as_str().into(), to.as_str().into()])?
755− .run()
756− .await?;
757− *gateway = if lines.is_empty() { GatewayRead::Empty { visible: keeper.gateway_visible().await } } else { GatewayRead::Rows };
758− written += self.upsert_lines(&lines, &fetched_at).await?;
759− }
760− Err(error) => {
761− *gateway = GatewayRead::Failed(error.clone());
762− problems.push(format!("AI Gateway's analytics could not be read: {error}"));
763− }
764− }
765586 }
766587 Ok(Some((since, until, written, bill_since)))
767588 }
973794 #[cfg(test)]
974795 mod tests {
975796 use super::*;
976−
977− /// AI Gateway's analytics in the shape of the schema
978− /// (`aiGatewayRequestsAdaptiveGroups`: `count`, `sum`, `dimensions`).
979− fn gateway_fixture() -> Value {
980− let group = |day: &str, provider: &str, model: &str, count: u64, cost: f64, tokens: (f64, f64, f64, f64)| {
981− json!({
982− "count": count,
983− "sum": { "cost": cost, "tokensIn": tokens.0, "tokensOut": tokens.1, "cacheReadTokens": tokens.2, "cacheWriteTokens": tokens.3 },
984− "dimensions": { "date": day, "provider": provider, "model": model }
985− })
986− };
987− json!({ "data": { "viewer": { "accounts": [{
988− "own": [
989− group("2026-10-05", "anthropic", "claude-sonnet-5-5", 120, 4.25, (900_000.0, 40_000.0, 3_000_000.0, 200_000.0)),
990− group("2026-10-05", "anthropic", "claude-haiku-4-5-20251001", 300, 0.40, (400_000.0, 20_000.0, 0.0, 0.0)),
991− group("2026-10-06", "anthropic", "claude-new-1", 12, 0.0, (80_000.0, 4_000.0, 0.0, 0.0)),
992− ],
993− "wholesale": [group("2026-10-06", "openai", "gpt-x", 5, 0.10, (1_000.0, 100.0, 0.0, 0.0))],
994− }] } }, "errors": null })
995− }
996−
997− #[test]
998− fn the_gateway_query_names_the_fields_its_schema_has() {
999− // As checked against Cloudflare's GraphQL schema (introspection of
1000− // AccountAiGatewayRequestsAdaptiveGroups{,Sum,Dimensions,Filter}).
1001− for field in ["aiGatewayRequestsAdaptiveGroups", "date_geq", "date_leq", "gateway: $gateway", "wholesale: 0", "wholesale: 1", "count", "cost", "tokensIn", "tokensOut", "cacheReadTokens", "cacheWriteTokens", "date", "provider", "model"] {
1002− assert!(GATEWAY_QUERY.contains(field), "{field}");
1003− }
1004− // Grouped by `wholesale`, Cloudflare answers no rows and no error:
1005− // it is only ever a filter.
1006− for dimensions in GATEWAY_QUERY.split("dimensions {").skip(1) {
1007− let dimensions = &dimensions[..dimensions.find('}').unwrap()];
1008− assert!(!dimensions.contains("wholesale"), "{dimensions}");
1009− }
1010− let body = gateway_variables("acct", "g1t", "2026-10-01", "2026-10-07");
1011− assert_eq!(body["variables"]["gateway"], "g1t");
1012− }
1013−
1014− #[test]
1015− fn what_the_gateway_priced_is_kept_per_day_and_model_with_its_tokens() {
1016− let lines = lines_from_gateway(&gateway_fixture()).unwrap();
1017− let get = |day: &str, meter: &str| lines.iter().find(|l| l.day == day && l.meter == meter).unwrap_or_else(|| panic!("{day} {meter}"));
1018− let sonnet = get("2026-10-05", "anthropic_claude_sonnet_5_5");
1019− assert_eq!((sonnet.source, sonnet.product.as_str(), sonnet.quantity, sonnet.cost_usd), (SOURCE_GATEWAY, GATEWAY_PRODUCT, 120.0, 4.25));
1020− assert_eq!(get("2026-10-05", "anthropic_claude_sonnet_5_5__tokens").quantity, 940_000.0);
1021− assert_eq!(get("2026-10-05", "anthropic_claude_sonnet_5_5__cache_read_tokens").quantity, 3_000_000.0);
1022− assert_eq!(get("2026-10-05", "anthropic_claude_sonnet_5_5__cache_write_tokens").cost_usd, 0.0);
1023− assert_eq!(get("2026-10-06", "wholesale__openai_gpt_x").cost_usd, 0.10);
1024− // Only the request lines carry cost: the day's total is the gateway's.
1025− let day5: f64 = lines.iter().filter(|l| l.day == "2026-10-05").map(|l| l.cost_usd).sum();
1026− assert!((day5 - 4.65).abs() < 1e-9);
1027− // Errors are a problem for the run, not lines.
1028− assert!(lines_from_gateway(&json!({ "errors": [{ "message": "not authorized for that account" }] })).is_err());
1029− }
1030−
1031− #[test]
1032− fn the_gateway_lines_say_when_its_cost_cannot_be_the_providers() {
1033− let rows: Vec<(String, f64, f64)> =
1034− lines_from_gateway(&gateway_fixture()).unwrap().into_iter().map(|l| (l.meter, l.quantity, l.cost_usd)).collect();
1035− let caveats = gateway_caveats(&rows);
1036− assert_eq!(caveats.unpriced, vec!["anthropic_claude_new_1".to_owned()]);
1037− assert_eq!((caveats.cache_read_tokens, caveats.cache_write_tokens), (3_000_000.0, 200_000.0));
1038− assert!((caveats.wholesale_usd - 0.10).abs() < 1e-9);
1039− // Every request it logged, priced or not, from both answers.
1040− assert_eq!(caveats.requests, 437.0);
1041− // A model priced on one day and not another is priced.
1042− let mixed = vec![
1043− ("m".to_owned(), 3.0, 0.5),
1044− ("m__tokens".to_owned(), 10.0, 0.0),
1045− ("m".to_owned(), 3.0, 0.0),
1046− ("m__tokens".to_owned(), 10.0, 0.0),
1047− ];
1048− assert!(gateway_caveats(&mixed).unpriced.is_empty());
1049− }
1050797
1051798 /// Billable usage as Cloudflare answered g1t on 2026-10-06 (two days,
1052799 /// trimmed), plus rows past the included amounts, which cost money.
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…