Skip to content

Commit

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.

syntaqxcommitted Parent6159630Browse files
5 files+44−150/5 viewed
+7−2
439439 read `cache.edge_hit` against `cache.miss` after a day; if the Cache API never hits from a
440440 Worker reached only by service bindings, put objects in KV instead. Still to do: caller
441441 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.
444449
445450 **2026-10-07: where the gap came from.** Cloudflare counted 581 operations (pull 535, push 39,
446451 create 3, fork 4) against g1t's 458 (`git.fetch` 417, `git.receive_pack` 33, ...). The suspicion
+2−2
4040 | Secret on g1t-billing | Permissions | Used for |
4141 | --- | --- | --- |
4242 | `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 |
4444
4545 With neither, the daily run reconciles only what g1t counted itself, and
4646 the page says the bill cannot be read. Nothing fails. To set the scoped one:
178178 | --- | --- | --- |
179179 | 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). |
180180 | 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. |
182182 | 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. |
183183 | 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`). |
184184
+1−1
581581 // the total the ledger's model cost is checked against (`margin`).
582582 if !keeper.gateway().is_empty() {
583583 match keeper
584− .graphql_either(gateway_variables(keeper.account(), keeper.gateway(), &since, &until))
584+ .gateway_graphql(gateway_variables(keeper.account(), keeper.gateway(), &since, &until))
585585 .await
586586 .map_err(|e| e.to_string())
587587 .and_then(|body| lines_from_gateway(&body))
+12−8
115115 &self.gateway
116116 }
117117
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 {
122128 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,
127131 _ => Err(error),
128132 },
129133 }
+22−2
437437 out.push(Drift { bucket: bucket.into(), kind: DriftKind::Cost, ours: own_cost, cloudflare: cf_cost, delta_percent: delta });
438438 }
439439 }
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+ }
440446 if !overhead && cf_cost >= min_cost_micros as f64 && value <= 0.0 {
441447 out.push(Drift { bucket: bucket.into(), kind: DriftKind::Leak, ours: value, cloudflare: cf_cost, delta_percent: None });
442448 }
473479
474480 /// The models drift's detail: the gateway's total against the ledger's.
475481 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+ }
476488 let lower = drift.ours < drift.cloudflare;
477489 let mut detail = format!(
478490 "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}%). {}",
20462058 // Gateway traffic with nothing on the ledger at all: cost drift and a leak.
20472059 let none = drifts("models", &[day("models", 2_000_000, 0, 0, 0.0, 0.0)], 10.0, false, 100_000);
20482060 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.
20502062 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());
20522072 // The detail says which way and why it may be off.
20532073 let caveats = costs::GatewayCaveats { cache_read_tokens: 3_000_000.0, unpriced: vec!["anthropic_claude_new_1".into()], ..Default::default() };
20542074 let detail = models_detail(&short[0], &caveats);