Billing: AI Gateway's analytics are read with the token that can see them, and a gateway that priced nothing is said
Cloudflare answers a token without AI Gateway Read with no rows, not an error. The gateway total was read with CLOUDFLARE_BILLING_TOKEN first (Billing and Account Analytics only) and fell back to CLOUDFLARE_USAGE_TOKEN only on an error, so it could read as $0, and a $0 gateway raised no Models drift: no row, which reads as agreement. Now the usage token (AI Gateway Read) asks first, the bill's token only if that fails, and a ledger with model cost against a gateway that priced nothing is a Models cost drift that names both causes. docs/ARTIFACTS.md: Cloudflare's client errors are explained. All are "read rejected", a readFile for a path that is not a file at that ref; tested with anonymous views of a missing file on a public repository (four views, four errors; a real file, none). Most come from crawlers on public blob pages.
| 439 | 439 | read `cache.edge_hit` against `cache.miss` after a day; if the Cache API never hits from a | |
| 440 | 440 | Worker reached only by service bindings, put objects in KV instead. Still to do: caller | |
| 441 | 441 | attribution in the meters. | |
| 442 | − | - 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every | |
| 443 | − | failed negotiation was one). | |
| 442 | + | - Client errors (576 on 2026-10-06, 985 on 2026-10-07) are all `read rejected`: a binding | |
| 443 | + | `readFile` for a path that is not a file at that ref (missing, or a directory). Tested on | |
| 444 | + | 2026-10-07: four anonymous views of a missing file on a public repository made four, a real | |
| 445 | + | file none. Nearly all came from crawlers (ClaudeBot, GPTBot) on public `blob/<sha>/…` pages | |
| 446 | + | and pull requests' working copies, hour after hour with no git at all. The site answers 404 | |
| 447 | + | correctly; each is one store read, and a miss is not cached. They are not operations. | |
| 448 | + | `--hours DAY` lists them by message and repository. | |
| 444 | 449 | ||
| 445 | 450 | **2026-10-07: where the gap came from.** Cloudflare counted 581 operations (pull 535, push 39, | |
| 446 | 451 | create 3, fork 4) against g1t's 458 (`git.fetch` 417, `git.receive_pack` 33, ...). The suspicion |
| 40 | 40 | | Secret on g1t-billing | Permissions | Used for | | |
| 41 | 41 | | --- | --- | --- | | |
| 42 | 42 | | `CLOUDFLARE_BILLING_TOKEN` (optional) | Account: **Billing Read**, Account: **Account Analytics Read**, for the g1t account only | Reading the bill, the Artifacts events and the subscriptions | | |
| 43 | − | | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with the bill's token first and, if that is refused, with this one | | |
| 43 | + | | `CLOUDFLARE_USAGE_TOKEN` (exists) | Billing Read, Account Analytics Read, AI Gateway Read | The keeper (settling runs from the gateway's logs); also the bill when `CLOUDFLARE_BILLING_TOKEN` is not set. AI Gateway's analytics are read with this token first and, if that is refused, with the bill's: Cloudflare answers a token without AI Gateway Read with no rows rather than an error, so the bill's token would read as a gateway that priced nothing | | |
| 44 | 44 | ||
| 45 | 45 | With neither, the daily run reconciles only what g1t counted itself, and | |
| 46 | 46 | the page says the bill cannot be read. Nothing fails. To set the scoped one: | |
| 178 | 178 | | --- | --- | --- | | |
| 179 | 179 | | Count | g1t's count and Cloudflare's differ by more than the mapping's `drift_percent` (10%) | Find out what Cloudflare counts: compare its events with `own_counts` `artifacts_*` and `cost_operations`. If it counts more (binding reads, `ls-refs`), either change repos' `operation_mapping` so customers are charged for what Cloudflare counts, or leave it and let the per-unit cost rise (below). | | |
| 180 | 180 | | Cost | Cloudflare charged more than `drift_percent` away from the price book's cost of the same usage, with at least `min_daily_cost` | A price is stale: check the proposals. | | |
| 181 | − | | Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. Compared once the gateway has been read; then a ledger with none of it is drift too | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. | | |
| 181 | + | | Cost, on `models` | What AI Gateway priced g1t's own provider traffic at over the 7 days, against the ledger's model cost for the same days (billed to g1t: comped, free and trial use included, a workspace's own provider not), more than the `ai_gateway_requests` mapping's `drift_percent` (10%) apart, with at least `min_daily_cost`. A ledger with none of the gateway's cost is drift too, and so is a gateway that priced nothing against a ledger with at least `min_daily_cost` of model cost (no percentage): that is not agreement, it is a token that cannot see AI Gateway, or calls that went around it | The gateway higher: model calls g1t paid for and charged no one: runs not settled yet (they catch up within the hour), runs with no session, a run started without a billing ticket, or something else on g1t's gateway. The ledger higher: runs that reached a provider without the gateway. The detail adds why the gateway's own figure may be off: prompt-cache read and write tokens (the gateway prices them at its rates for cache tokens, which can lag the provider's; check against the provider's invoice), requests Cloudflare billed itself (unified billing: on Cloudflare's bill, not a provider's), and models with no price. Days are UTC by when a request ran (gateway) and when a charge was entered (ledger), so a run across midnight shifts a little between days; the 7-day sum absorbs it. | | |
| 182 | 182 | | Unpriced | Over the 7 days, a model in AI Gateway's analytics with tokens and $0 cost, or runs settled with `runs.gateway_note` (the gateway could not price all of a run) | The gateway has no price for a model g1t runs: add it in the gateway (custom cost) or route away from it. Until then those runs are charged no less than the sandbox reported (Claude Code's own price table), never $0 silently. | | |
| 183 | 183 | | Leak | Cost of at least `min_daily_cost` and nothing charged for it (never for `platform`), or a meter in `unmapped` | Map the meter (below), or decide it is overhead (`platform`). | | |
| 184 | 184 |
| 581 | 581 | // the total the ledger's model cost is checked against (`margin`). | |
| 582 | 582 | if !keeper.gateway().is_empty() { | |
| 583 | 583 | match keeper | |
| 584 | − | .graphql_either(gateway_variables(keeper.account(), keeper.gateway(), &since, &until)) | |
| 584 | + | .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &since, &until)) | |
| 585 | 585 | .await | |
| 586 | 586 | .map_err(|e| e.to_string()) | |
| 587 | 587 | .and_then(|body| lines_from_gateway(&body)) |
| 115 | 115 | &self.gateway | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | − | /// A GraphQL query with the bill's token, and on failure with the | |
| 119 | − | /// keeper's (AI Gateway Read), when that is a different token. | |
| 120 | − | pub(crate) async fn graphql_either(&self, body: Value) -> Result<Value> { | |
| 121 | − | match self.graphql(body.clone()).await { | |
| 118 | + | /// A GraphQL query over AI Gateway's analytics: with the keeper's token | |
| 119 | + | /// (AI Gateway Read) first, and on failure with the bill's. Not the | |
| 120 | + | /// other way round: Cloudflare answers a token that cannot see AI | |
| 121 | + | /// Gateway with no rows, not an error, so the bill's token would read | |
| 122 | + | /// as a gateway that priced nothing. | |
| 123 | + | pub(crate) async fn gateway_graphql(&self, body: Value) -> Result<Value> { | |
| 124 | + | let Some(token) = &self.token else { | |
| 125 | + | return self.graphql(body).await; | |
| 126 | + | }; | |
| 127 | + | match send_with(token, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body.clone())).await { | |
| 122 | 128 | Ok(answer) => Ok(answer), | |
| 123 | − | Err(error) => match &self.token { | |
| 124 | − | Some(token) if Some(token) != self.billing_token.as_ref() => { | |
| 125 | − | send_with(token, Method::Post, "https://api.cloudflare.com/client/v4/graphql", Some(body)).await | |
| 126 | − | } | |
| 129 | + | Err(error) => match &self.billing_token { | |
| 130 | + | Some(billing) if billing != token => self.graphql(body).await, | |
| 127 | 131 | _ => Err(error), | |
| 128 | 132 | }, | |
| 129 | 133 | } |
| 437 | 437 | out.push(Drift { bucket: bucket.into(), kind: DriftKind::Cost, ours: own_cost, cloudflare: cf_cost, delta_percent: delta }); | |
| 438 | 438 | } | |
| 439 | 439 | } | |
| 440 | + | // The ledger has model cost and the gateway priced none of it: a token | |
| 441 | + | // that cannot see AI Gateway reads as no rows, never an error, so this | |
| 442 | + | // is not agreement. Said, rather than left as no row at all. | |
| 443 | + | if NOT_CLOUDFLARE.contains(&bucket) && cf_cost <= 0.0 && own_cost >= min_cost_micros as f64 && own_cost > 0.0 { | |
| 444 | + | out.push(Drift { bucket: bucket.into(), kind: DriftKind::Cost, ours: own_cost, cloudflare: 0.0, delta_percent: None }); | |
| 445 | + | } | |
| 440 | 446 | if !overhead && cf_cost >= min_cost_micros as f64 && value <= 0.0 { | |
| 441 | 447 | out.push(Drift { bucket: bucket.into(), kind: DriftKind::Leak, ours: value, cloudflare: cf_cost, delta_percent: None }); | |
| 442 | 448 | } | |
| 473 | 479 | ||
| 474 | 480 | /// The models drift's detail: the gateway's total against the ledger's. | |
| 475 | 481 | pub(crate) fn models_detail(drift: &Drift, caveats: &costs::GatewayCaveats) -> String { | |
| 482 | + | if drift.cloudflare <= 0.0 { | |
| 483 | + | return format!( | |
| 484 | + | "Models: the ledger's model cost is {} over the last {DRIFT_DAYS} days and AI Gateway priced nothing, so the two were not compared. Either the gateway's analytics cannot be seen (Cloudflare answers a token without AI Gateway: Read with no rows, not an error; billing reads them with CLOUDFLARE_USAGE_TOKEN, then CLOUDFLARE_BILLING_TOKEN), or model calls went around the gateway.", | |
| 485 | + | dollars(drift.ours as i64) | |
| 486 | + | ); | |
| 487 | + | } | |
| 476 | 488 | let lower = drift.ours < drift.cloudflare; | |
| 477 | 489 | let mut detail = format!( | |
| 478 | 490 | "Models: AI Gateway priced g1t's own provider traffic at {} over the last {DRIFT_DAYS} days; the ledger's model cost for the same days is {} ({:+.1}%). {}", | |
| 2046 | 2058 | // Gateway traffic with nothing on the ledger at all: cost drift and a leak. | |
| 2047 | 2059 | let none = drifts("models", &[day("models", 2_000_000, 0, 0, 0.0, 0.0)], 10.0, false, 100_000); | |
| 2048 | 2060 | assert_eq!(none.iter().map(|d| d.kind).collect::<Vec<_>>(), vec![DriftKind::Cost, DriftKind::Leak]); | |
| 2049 | − | // Within the threshold, or before the gateway was ever read: nothing. | |
| 2061 | + | // Within the threshold: nothing. | |
| 2050 | 2062 | assert!(drifts("models", &[day("models", 1_050_000, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2051 | − | assert!(drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2063 | + | // The gateway priced nothing against a ledger that has model cost: | |
| 2064 | + | // not agreement (a token that cannot see AI Gateway reads as no | |
| 2065 | + | // rows), so it is said. Under the minimum, or no model cost: nothing. | |
| 2066 | + | let silent = drifts("models", &[day("models", 0, 1_000_000, 1_200_000, 0.0, 0.0)], 10.0, false, 100_000); | |
| 2067 | + | assert_eq!(silent, vec![Drift { bucket: "models".into(), kind: DriftKind::Cost, ours: 1_000_000.0, cloudflare: 0.0, delta_percent: None }]); | |
| 2068 | + | let said = models_detail(&silent[0], &costs::GatewayCaveats::default()); | |
| 2069 | + | assert!(said.contains("$1.00") && said.contains("priced nothing") && said.contains("AI Gateway: Read"), "{said}"); | |
| 2070 | + | assert!(drifts("models", &[day("models", 0, 50_000, 60_000, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2071 | + | assert!(drifts("models", &[day("models", 0, 0, 0, 0.0, 0.0)], 10.0, false, 100_000).is_empty()); | |
| 2052 | 2072 | // The detail says which way and why it may be off. | |
| 2053 | 2073 | let caveats = costs::GatewayCaveats { cache_read_tokens: 3_000_000.0, unpriced: vec!["anthropic_claude_new_1".into()], ..Default::default() }; | |
| 2054 | 2074 | let detail = models_detail(&short[0], &caveats); |