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.
49 files+88−319333 files not counted0/49 viewed
| 1 | − | //! Billing: a workspace's usage, budget, AI credit and invoices. The | |
| 1 | + | //! Billing: a workspace's usage, budget and invoices. The | |
| 2 | 2 | //! billing service keeps them and answers in camelCase; this is their | |
| 3 | 3 | //! public shape, in snake_case, with money as whole millionths of a dollar | |
| 4 | 4 | //! (`_micros`) or, for invoices, cents (`_cents`). | |
| 5 | 5 | //! | |
| 6 | 6 | //! Reading is for the workspace's members, a workspace's own token | |
| 7 | − | //! included. Changing the budget and buying AI credit are for its owners, | |
| 7 | + | //! included. Changing the budget is for its owners, | |
| 8 | 8 | //! as people: signed in or with a personal access token. A workspace's | |
| 9 | 9 | //! token and a run's token never change billing, whatever their scopes say. | |
| 10 | 10 | ||
| ⋯ | |||
| 16 | 16 | ||
| 17 | 17 | use crate::operations::{Op, Services}; | |
| 18 | 18 | ||
| 19 | − | /// The product families usage is grouped into, in order. | |
| 20 | − | pub(crate) const PRODUCTS: [&str; 8] = | |
| 21 | − | ["agent", "sandboxes", "gateway", "deployments", "git_storage", "packages", "security", "search"]; | |
| 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"]; | |
| 22 | 23 | ||
| 23 | 24 | /// Where a budget's alerts can be, in percent of its limit. | |
| 24 | 25 | pub(crate) const ALERT_LEVELS: [u32; 4] = [50, 75, 90, 100]; | |
| ⋯ | |||
| 109 | 110 | None => Err((FailureCode::Unauthenticated, "This needs a g1t access token.")), | |
| 110 | 111 | Some(user) if agent_scoped || user.kind == PrincipalKind::Agent => Err(( | |
| 111 | 112 | 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.", | |
| 113 | 114 | )), | |
| 114 | 115 | Some(user) if user.kind != PrincipalKind::User => Err(( | |
| 115 | 116 | FailureCode::Forbidden, | |
| ⋯ | |||
| 375 | 376 | ) | |
| 376 | 377 | .await?; | |
| 377 | 378 | shaped(set, &budget_json) | |
| 378 | − | } | |
| 379 | − | Op::GetAiCredit => shaped(g1t_kit::call(billing, "ai_credit", &account).await?, &snake), | |
| 380 | − | Op::BuyAiCredit => { | |
| 381 | − | let actor = match person(viewer, services.scope.is_some()) { | |
| 382 | − | Ok(user) => user, | |
| 383 | − | Err((code, message)) => return failed(code, message), | |
| 384 | − | }; | |
| 385 | − | let cents = match &input["amount_cents"] { | |
| 386 | − | Value::Number(number) => number.as_u64(), | |
| 387 | − | Value::String(digits) => digits.trim().parse().ok(), | |
| 388 | − | _ => None, | |
| 389 | − | }; | |
| 390 | − | let Some(cents) = cents.and_then(|cents| u32::try_from(cents).ok()).filter(|cents| *cents > 0) else { | |
| 391 | − | return failed(FailureCode::Invalid, "Give amount_cents: the credit in cents, in whole dollars, such as 5000 for $50."); | |
| 392 | − | }; | |
| 393 | − | let return_url = format!("{}/{workspace}/-/billing", services.addresses.site.trim_end_matches('/')); | |
| 394 | − | let checkout: Outcome<Value> = g1t_kit::call( | |
| 395 | − | billing, | |
| 396 | − | "buy_ai_credit", | |
| 397 | − | &json!({ "actor": actor, "workspace": workspace, "amountCents": cents, "returnUrl": return_url }), | |
| 398 | − | ) | |
| 399 | − | .await?; | |
| 400 | − | shaped(checkout, &|checkout| json!({ "url": checkout["url"] })) | |
| 401 | 379 | } | |
| 402 | 380 | Op::ListInvoices => { | |
| 403 | 381 | let details: Outcome<Value> = g1t_kit::call(billing, "billing_details", &account).await?; | |
| ⋯ | |||
| 427 | 405 | "totals": { "priceMicros": 12_500_000, "discountMicros": 0, "includedMicros": 2_000_000, "creditsMicros": 500_000, | |
| 428 | 406 | "chargedMicros": 10_000_000, "pendingMicros": 300_000, "costMicros": 9_000_000 }, | |
| 429 | 407 | "days": [ | |
| 430 | − | { "day": "2026-10-02", "product": "agent", "micros": 4_000_000 }, | |
| 408 | + | { "day": "2026-10-02", "product": "other", "micros": 4_000_000 }, | |
| 431 | 409 | { "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 }, | |
| 433 | 411 | ], | |
| 434 | 412 | "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, | |
| 437 | 415 | "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 }] }] }, | |
| 440 | 418 | { "key": "sandboxes", "label": "Sandboxes", "micros": 500_000, "features": [], | |
| 441 | 419 | "meters": [{ "key": "sandbox", "label": "Sandbox time", "product": "sandboxes", "unit": "seconds", "quantity": 600.0, | |
| 442 | 420 | "micros": 500_000, "pendingMicros": 0, "daily": [0, 500_000], | |
| ⋯ | |||
| 470 | 448 | } | |
| 471 | 449 | ||
| 472 | 450 | #[test] | |
| 473 | − | fn ai_credit_is_snake_case() { | |
| 474 | − | let credit = json!({ | |
| 475 | − | "balanceMicros": 42_000_000, "purchasedMicros": 40_000_000, "givenMicros": 2_000_000, | |
| 476 | − | "grants": [{ "id": "crd_1", "kind": "purchase", "amountMicros": 40_000_000, "usedMicros": 0, "leftMicros": 40_000_000, "expiresAt": null }], | |
| 477 | − | "freeViaDiscount": false, "postpaid": false, "blocked": false, "canBuy": true, | |
| 478 | − | "presetsCents": [2500, 5000], "minCents": 1000, "maxCents": 100_000, | |
| 479 | − | "cardFee": { "on": true, "percentMicros": 29_000.0, "fixedCents": 30 }, | |
| 480 | − | "reload": { "enabled": false, "thresholdMicros": 0, "targetMicros": 0, "monthlyMaxMicros": 0, "reloadedMicros": 0, "failedAt": null, "error": null }, | |
| 481 | − | "agentRateMicros": 3.6, "modelMarkupPercent": 10, "gatewayMarkupPercent": 5, "upgradeCreditMicros": 0, "expiresDays": 365, | |
| 482 | − | }); | |
| 483 | − | let out = snake(&credit); | |
| 484 | − | assert_eq!(out["balance_micros"], 42_000_000); | |
| 485 | − | assert_eq!(out["grants"][0]["left_micros"], 40_000_000); | |
| 486 | − | assert_eq!(out["card_fee"]["fixed_cents"], 30); | |
| 487 | − | assert_eq!(out["reload"]["monthly_max_micros"], 0); | |
| 488 | − | assert_eq!(out["can_buy"], true); | |
| 489 | − | assert!(out.get("balanceMicros").is_none()); | |
| 490 | − | assert_eq!(snake_key("line1"), "line1"); | |
| 491 | − | assert_eq!(snake_key("last4"), "last4"); | |
| 492 | − | assert_eq!(snake_key("taxIdType"), "tax_id_type"); | |
| 493 | − | } | |
| 494 | − | ||
| 495 | − | #[test] | |
| 496 | 451 | fn agents_and_workspace_tokens_never_change_billing() { | |
| 497 | 452 | let person_user = User { username: "ana".into(), ..User::default() }; | |
| 498 | 453 | assert!(person(&Some(person_user.clone()), false).is_ok()); | |
| ⋯ | |||
| 546 | 501 | "unavailable": null, | |
| 547 | 502 | }); | |
| 548 | 503 | 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 }], | |
| 550 | 505 | "createdAt": "2026-10-01T00:00:00Z" })]; | |
| 551 | 506 | let out = invoices_json(&details, &usage); | |
| 552 | 507 | assert_eq!(out["invoices"][0]["total_cents"], 2000); | |
| ⋯ | |||
| 559 | 514 | assert!(shown.get("invoices").is_none() && shown.get("upcoming").is_none()); | |
| 560 | 515 | } | |
| 561 | 516 | ||
| 562 | − | const OPS: [Op; 7] = [ | |
| 517 | + | const OPS: [Op; 5] = [ | |
| 563 | 518 | Op::GetUsage, | |
| 564 | 519 | Op::GetBudget, | |
| 565 | 520 | Op::SetBudget, | |
| 566 | − | Op::GetAiCredit, | |
| 567 | − | Op::BuyAiCredit, | |
| 568 | 521 | Op::ListInvoices, | |
| 569 | 522 | Op::GetBillingDetails, | |
| 570 | 523 | ]; | |
| ⋯ | |||
| 592 | 545 | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 593 | 546 | let access = token(preset); | |
| 594 | 547 | 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()); | |
| 596 | 549 | } | |
| 597 | 550 | let full = TokenAccess::full(); | |
| 598 | 551 | assert_eq!(tool.visible(&Gate::Token(&full)).len(), OPS.len()); | |
| ⋯ | |||
| 606 | 559 | // 2024-02-29. | |
| 607 | 560 | assert_eq!(this_month(1_709_208_000_000.0), ("2024-02-01".to_owned(), "2024-02-29".to_owned())); | |
| 608 | 561 | 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"]); | |
| 611 | 564 | } | |
| 612 | 565 | } | |
| 101 | 101 | ), | |
| 102 | 102 | ( | |
| 103 | 103 | "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.", | |
| 105 | 105 | &[ | |
| 106 | 106 | Op::GetUsage, | |
| 107 | 107 | Op::GetBudget, | |
| 108 | 108 | Op::SetBudget, | |
| 109 | − | Op::GetAiCredit, | |
| 110 | − | Op::BuyAiCredit, | |
| 111 | 109 | Op::ListInvoices, | |
| 112 | 110 | Op::GetBillingDetails, | |
| 113 | 111 | ], | |
| ⋯ | |||
| 759 | 757 | Op::GetUsage => "Get a workspace's usage", | |
| 760 | 758 | Op::GetBudget => "Get a workspace's budget", | |
| 761 | 759 | Op::SetBudget => "Change a workspace's budget", | |
| 762 | − | Op::GetAiCredit => "Get a workspace's AI credit", | |
| 763 | − | Op::BuyAiCredit => "Buy AI credit", | |
| 764 | 760 | Op::ListInvoices => "List a workspace's invoices", | |
| 765 | 761 | Op::GetBillingDetails => "Get a workspace's billing details", | |
| 766 | 762 | Op::PinProject => "Pin a project", | |
| 283 | 283 | GetUsage, | |
| 284 | 284 | GetBudget, | |
| 285 | 285 | SetBudget, | |
| 286 | − | GetAiCredit, | |
| 287 | − | BuyAiCredit, | |
| 288 | 286 | ListInvoices, | |
| 289 | 287 | GetBillingDetails, | |
| 290 | 288 | RequestReviewers, | |
| ⋯ | |||
| 693 | 691 | } | |
| 694 | 692 | ||
| 695 | 693 | impl Op { | |
| 696 | − | pub const ALL: [Op; 368] = [ | |
| 694 | + | pub const ALL: [Op; 366] = [ | |
| 697 | 695 | Op::Whoami, | |
| 698 | 696 | Op::GetWorkspace, | |
| 699 | 697 | Op::CreateWorkspace, | |
| ⋯ | |||
| 868 | 866 | Op::GetUsage, | |
| 869 | 867 | Op::GetBudget, | |
| 870 | 868 | Op::SetBudget, | |
| 871 | − | Op::GetAiCredit, | |
| 872 | − | Op::BuyAiCredit, | |
| 873 | 869 | Op::ListInvoices, | |
| 874 | 870 | Op::GetBillingDetails, | |
| 875 | 871 | Op::RequestReviewers, | |
| ⋯ | |||
| 1245 | 1241 | Op::GetUsage => "get_usage", | |
| 1246 | 1242 | Op::GetBudget => "get_budget", | |
| 1247 | 1243 | Op::SetBudget => "set_budget", | |
| 1248 | − | Op::GetAiCredit => "get_ai_credit", | |
| 1249 | − | Op::BuyAiCredit => "buy_ai_credit", | |
| 1250 | 1244 | Op::ListInvoices => "list_invoices", | |
| 1251 | 1245 | Op::GetBillingDetails => "get_billing_details", | |
| 1252 | 1246 | Op::RequestReviewers => "request_reviewers", | |
| ⋯ | |||
| 1746 | 1740 | "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." | |
| 1747 | 1741 | } | |
| 1748 | 1742 | 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." | |
| 1750 | 1744 | } | |
| 1751 | 1745 | Op::GetBudget => { | |
| 1752 | 1746 | "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." | |
| ⋯ | |||
| 1754 | 1748 | Op::SetBudget => { | |
| 1755 | 1749 | "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." | |
| 1756 | 1750 | } | |
| 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 | − | } | |
| 1763 | 1751 | 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." | |
| 1765 | 1753 | } | |
| 1766 | 1754 | Op::GetBillingDetails => { | |
| 1767 | 1755 | "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." | |
| ⋯ | |||
| 3086 | 3074 | }), | |
| 3087 | 3075 | &["workspace"], | |
| 3088 | 3076 | ), | |
| 3089 | − | Op::GetBudget | Op::GetAiCredit | Op::ListInvoices | Op::GetBillingDetails => { | |
| 3077 | + | Op::GetBudget | Op::ListInvoices | Op::GetBillingDetails => { | |
| 3090 | 3078 | object(json!({ "workspace": workspace_schema() }), &["workspace"]) | |
| 3091 | 3079 | } | |
| 3092 | 3080 | Op::SetBudget => object( | |
| ⋯ | |||
| 3109 | 3097 | }, | |
| 3110 | 3098 | }), | |
| 3111 | 3099 | &["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"], | |
| 3125 | 3100 | ), | |
| 3126 | 3101 | Op::ListUserTeams => object( | |
| 3127 | 3102 | json!({ "workspace": workspace_schema(), "username": username_schema() }), | |
| ⋯ | |||
| 3365 | 3340 | | Op::GetUsage | |
| 3366 | 3341 | | Op::GetBudget | |
| 3367 | 3342 | | Op::SetBudget | |
| 3368 | − | | Op::GetAiCredit | |
| 3369 | − | | Op::BuyAiCredit | |
| 3370 | 3343 | | Op::ListInvoices | |
| 3371 | 3344 | | Op::GetBillingDetails | |
| 3372 | 3345 | ) | |
| ⋯ | |||
| 5278 | 5251 | Op::GetUsage | |
| 5279 | 5252 | | Op::GetBudget | |
| 5280 | 5253 | | Op::SetBudget | |
| 5281 | − | | Op::GetAiCredit | |
| 5282 | − | | Op::BuyAiCredit | |
| 5283 | 5254 | | Op::ListInvoices | |
| 5284 | 5255 | | Op::GetBillingDetails => crate::billing::run(self, services, viewer, input).await, | |
| 5285 | 5256 | Op::ListUserTeams => { | |
| 5767 | 5767 | "days": [ | |
| 5768 | 5768 | { | |
| 5769 | 5769 | "day": "2026-10-06", | |
| 5770 | − | "product": "agent", | |
| 5770 | + | "product": "other", | |
| 5771 | 5771 | "micros": 12400000 | |
| 5772 | 5772 | }, | |
| 5773 | 5773 | { | |
| ⋯ | |||
| 5777 | 5777 | }, | |
| 5778 | 5778 | { | |
| 5779 | 5779 | "day": "2026-10-07", | |
| 5780 | − | "product": "agent", | |
| 5780 | + | "product": "other", | |
| 5781 | 5781 | "micros": 9800000 | |
| 5782 | 5782 | } | |
| 5783 | 5783 | ], | |
| 5784 | 5784 | "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 | − | }, | |
| 5837 | 5785 | { | |
| 5838 | 5786 | "key": "sandboxes", | |
| 5839 | 5787 | "label": "Sandboxes", | |
| ⋯ | |||
| 5871 | 5819 | } | |
| 5872 | 5820 | ], | |
| 5873 | 5821 | "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": [] | |
| 5874 | 5861 | } | |
| 5875 | 5862 | ], | |
| 5876 | 5863 | "projects": [ | |
| 5877 | 5864 | "flagon-io/g1t", | |
| 5878 | 5865 | "flagon-io/hello" | |
| 5879 | 5866 | ], | |
| 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": [], | |
| 5917 | 5870 | "included": { | |
| 5918 | 5871 | "used": 20, | |
| 5919 | 5872 | "of": 20, | |
| ⋯ | |||
| 5937 | 5890 | } | |
| 5938 | 5891 | ] | |
| 5939 | 5892 | }, | |
| 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`." | |
| 5941 | 5894 | }, | |
| 5942 | 5895 | "get_budget": { | |
| 5943 | 5896 | "params": { | |
| ⋯ | |||
| 5994 | 5947 | "message": null | |
| 5995 | 5948 | }, | |
| 5996 | 5949 | "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`." | |
| 6071 | 5950 | }, | |
| 6072 | 5951 | "list_invoices": { | |
| 6073 | 5952 | "params": { | |
| 276 | 276 | &[], | |
| 277 | 277 | ), | |
| 278 | 278 | 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. | |
| 280 | 280 | route( | |
| 281 | 281 | "GET", | |
| 282 | 282 | "/workspaces/:workspace/usage", | |
| ⋯ | |||
| 285 | 285 | ), | |
| 286 | 286 | route("GET", "/workspaces/:workspace/budget", Op::GetBudget, &[]), | |
| 287 | 287 | 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, &[]), | |
| 290 | 288 | route("GET", "/workspaces/:workspace/invoices", Op::ListInvoices, &[]), | |
| 291 | 289 | route("GET", "/workspaces/:workspace/billing_details", Op::GetBillingDetails, &[]), | |
| 292 | 290 | // The AI Gateway's log of a workspace's requests. | |
| ⋯ | |||
| 1444 | 1442 | assert_eq!(input, json!({ "alerts": [50], "workspace": "acme" })); | |
| 1445 | 1443 | let op = |method: &str, path: &str| resolve(method, path, &[], Value::Null).unwrap().0.op; | |
| 1446 | 1444 | 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); | |
| 1449 | 1445 | assert_eq!(op("GET", "/workspaces/acme/invoices"), Op::ListInvoices); | |
| 1450 | 1446 | assert_eq!(op("GET", "/workspaces/acme/billing_details"), Op::GetBillingDetails); | |
| 1451 | 1447 | } | |
| 430 | 430 | Tool { | |
| 431 | 431 | name: "billing", | |
| 432 | 432 | 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.", | |
| 434 | 434 | default_action: Some("usage"), | |
| 435 | 435 | actions: &[ | |
| 436 | 436 | a("usage", Op::GetUsage, "Usage over a range of days, by product, meter, project and day, and what paid for it"), | |
| 437 | 437 | a("budget", Op::GetBudget, "The monthly spend limit, what was spent, alerts and whether usage pauses at the limit"), | |
| 438 | 438 | 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"), | |
| 441 | 439 | a("invoices", Op::ListInvoices, "Every invoice, the itemised usage invoices, and the next one so far"), | |
| 442 | 440 | a("billing_details", Op::GetBillingDetails, "Who invoices are made out to and the payment method on file"), | |
| 443 | 441 | ], | |
| ⋯ | |||
| 861 | 859 | "set_plugin_enabled", | |
| 862 | 860 | "uninstall_plugin", | |
| 863 | 861 | "update_plugin", | |
| 862 | + | "get_ai_credit", | |
| 863 | + | "buy_ai_credit", | |
| 864 | 864 | "list_plugin_agents", | |
| 865 | 865 | "list_connectors", | |
| 866 | 866 | "get_connector", | |
| 777 | 777 | | Applications you signed in to, and signing in with GitHub | Settings → Connected applications, Settings → GitHub | | |
| 778 | 778 | | Letting a device or an application sign in | `g1t.sh/device`, `g1t.sh/oauth/authorize` | | |
| 779 | 779 | | 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 | | |
| 781 | 781 | ||
| 782 | 782 | ### Chat and notifications without the website | |
| 783 | 783 |
| 229 | 229 | | [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. | | |
| 230 | 230 | | [Workspaces](/reference/api/workspaces/create-workspace/) | Creating a workspace. | | |
| 231 | 231 | | [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/). | | |
| 233 | 233 | | [Invites](/reference/api/invites/list-invites/) | Your invites while g1t is invite-only, and inviting people into a workspace by email. | | |
| 234 | 234 | | [Repositories](/reference/api/repositories/list-repos/) | A repository, how it handles pull requests, and its timeline. | | |
| 235 | 235 | | [Access](/reference/api/access/list-collaborators/) | Who has which role on a repository, invitations, outside collaborators, and a workspace's base permission. | |
| 686 | 686 | ||
| 687 | 687 | ## `billing` | |
| 688 | 688 | ||
| 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/). | |
| 697 | 696 | ||
| 698 | 697 | | Action | What it does | Required | Scope | | |
| 699 | 698 | | --- | --- | --- | --- | | |
| 700 | 699 | | [`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` | | |
| 701 | 700 | | [`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` | | |
| 702 | 701 | | [`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` | | |
| 705 | 702 | | [`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` | | |
| 706 | 703 | | [`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` | | |
| 707 | 704 |
Binary or large file; its contents are not shown.
| 758 | 758 | #[test] | |
| 759 | 759 | fn a_discount_below_cost_plus_the_margin_is_counted_as_given() { | |
| 760 | 760 | // $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); | |
| 762 | 762 | assert_eq!(base, 1_200_000); | |
| 763 | 763 | // Standard: all of it sold, nothing given. | |
| 764 | 764 | assert_eq!(terms(TermsKind::Standard, 0).discounted(base), (1_200_000, 0)); |
A large diff (1,218 lines) is not shown until you ask for it.
| 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 | − | } |
A large diff (1,051 lines) is not shown until you ask for it.
| 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 | − | } |
| 319 | 319 | } | |
| 320 | 320 | ||
| 321 | 321 | /// 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. | |
| 323 | 324 | 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"; | |
| 331 | 325 | /// The meter prefix of requests Cloudflare billed itself (unified billing), | |
| 332 | 326 | /// which are on Cloudflare's bill as well as here. | |
| 333 | 327 | pub(crate) const GATEWAY_WHOLESALE: &str = "wholesale__"; | |
| 334 | 328 | ||
| 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 | − | ||
| 470 | 329 | /// The workspace a repository in the store belongs to: keys are | |
| 471 | 330 | /// `<workspace>--<repo>`; a pull request's working copy (`pulls--<id>`) is | |
| 472 | 331 | /// its repository's workspace's, from `owners` (repos' `pull_owners`), else | |
| ⋯ | |||
| 659 | 518 | /// Reads Cloudflare's bill into `cost_lines`: the billable usage over | |
| 660 | 519 | /// whole billing cycles (`cycle::bill_since`), every page of it, priced | |
| 661 | 520 | /// 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`. | |
| 667 | 525 | pub(crate) async fn read_cloudflare( | |
| 668 | 526 | &self, | |
| 669 | 527 | keeper: &Keeper, | |
| 670 | 528 | problems: &mut Vec<String>, | |
| 671 | − | gateway: &mut GatewayRead, | |
| 672 | 529 | ) -> Result<Option<(String, String, u32, String)>> { | |
| 673 | 530 | if !keeper.can_read_bill() { | |
| 674 | 531 | return Ok(None); | |
| ⋯ | |||
| 726 | 583 | Err(error) => problems.push(format!("Artifacts events could not be read: {error}")), | |
| 727 | 584 | }, | |
| 728 | 585 | 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 | − | } | |
| 765 | 586 | } | |
| 766 | 587 | Ok(Some((since, until, written, bill_since))) | |
| 767 | 588 | } | |
| ⋯ | |||
| 973 | 794 | #[cfg(test)] | |
| 974 | 795 | mod tests { | |
| 975 | 796 | 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 | − | } | |
| 1050 | 797 | ||
| 1051 | 798 | /// Billable usage as Cloudflare answered g1t on 2026-10-06 (two days, | |
| 1052 | 799 | /// 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…