Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

3 commits

11 files+197−350/11 viewed
+9−4
11 import { Link } from "react-router";
22
3−import { cloneUrl, useAddresses } from "../lib/addresses";
3+import { cloneUrl, sshUrl, useAddresses } from "../lib/addresses";
44 import { AgentSetup } from "./agent-setup";
55 import { CopyLine } from "./ui";
66 import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
2626 </p>
2727 </TabsContent>
2828 <TabsContent value="ssh">
29− <p className="rounded-lg border border-dashed border-line p-3 text-xs text-muted">
30− Git over SSH is waiting on inbound TCP on Cloudflare, which g1t has applied for. Use HTTPS for now: it
31− clones, fetches and pushes the same. Keys you add under Settings → SSH keys will work as soon as SSH is on.
29+ <CopyLine text={sshUrl(addresses, path)} disabled />
30+ <p className="mt-2 text-xs text-muted">
31+ Not on yet: git over SSH is waiting on inbound TCP on Cloudflare, which g1t has applied for. Use HTTPS for
32+ now; it clones, fetches and pushes the same. Keys you add under{" "}
33+ <Link to="/settings/keys" className="text-fg underline underline-offset-4">
34+ SSH keys
35+ </Link>{" "}
36+ will work as soon as SSH is on.
3237 </p>
3338 </TabsContent>
3439 <TabsContent value="agent">
+9−2
348348 export function CopyLine({
349349 text,
350350 prompt,
351+ disabled,
351352 }: {
352353 text: string;
353354 prompt?: boolean;
355+ /** Shown, so it is clear what will be there, but not yet usable: dimmed, with no copy. */
356+ disabled?: boolean;
354357 }) {
355358 const [copied, setCopied] = useState(false);
356359 return (
357− <div className="group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem]">
360+ <div
361+ aria-disabled={disabled || undefined}
362+ className={`group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem] ${disabled ? "cursor-not-allowed text-faint select-none" : ""}`}
363+ >
358364 {/* Wraps rather than hides: a command or an address is no use half seen. */}
359365 <code className="min-w-0 grow whitespace-pre-wrap [overflow-wrap:anywhere]">
360366 {prompt && <span className="mr-2 text-faint select-none">$</span>}
373379 <button
374380 type="button"
375381 aria-label="Copy"
376− className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
382+ disabled={disabled}
383+ className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-faint"
377384 onClick={() => {
378385 void navigator.clipboard.writeText(text);
379386 setCopied(true);
+5−0
5454 return addressesFrom(useRouteLoaderData("root"));
5555 }
5656
57+/** The SSH clone address of a repository (`git@host:owner/name.git`), from its `owner/name`. */
58+export function sshUrl(addresses: Pick<Addresses, "site">, path: string): string {
59+ return `git@${new URL(addresses.site).hostname}:${path.replace(/^\/+/, "")}.git`;
60+}
61+
5762 /** The HTTPS clone address of a repository, from its `owner/name`. */
5863 export function cloneUrl(addresses: Pick<Addresses, "site">, path: string): string {
5964 return `${addresses.site}/${path.replace(/^\/+/, "")}.git`;
+9−1
11 import { identity } from "../../lib/services.server";
2−import { Form } from "react-router";
2+import { Form, Link } from "react-router";
33
44 import type { Route } from "./+types/keys";
55 import { page } from "../../lib/meta";
4040 const { keys } = loaderData;
4141 return (
4242 <section id="ssh-keys" className="scroll-mt-20">
43+ <p className="mb-4 rounded-lg border border-dashed border-line p-3 text-xs text-muted">
44+ Git over SSH is not on yet: it is waiting on inbound TCP on Cloudflare, which g1t has applied for. Keys you
45+ add now will work as soon as it is. Until then, clone and push over HTTPS with an{" "}
46+ <Link to="/settings/tokens" className="text-fg underline underline-offset-4">
47+ access token
48+ </Link>
49+ .
50+ </p>
4351 <ul className="divide-y divide-line rounded-md border border-line empty:hidden">
4452 {keys.map((key) => (
4553 <li key={key.id} className="flex items-center gap-4 px-4 py-3">
+4−0
44 //!
55 //! Connections arrive either as raw TCP or wrapped in a WebSocket, which is
66 //! how they reach a Cloudflare Container when tunnelled through a Worker.
7+//!
8+//! Not deployed. Before it ships, its git operations must be metered: the
9+//! bridge reaches the store directly, so `git_http` never counts them (see
10+//! docs/ARTIFACTS.md, "where the gap came from").
711
812 mod api;
913 mod git;
+27−4
348348 workspaces, because the 1 TB account limit would stop every push.
349349 - **Before agent volume ramps (about 1,000 pull requests a day):** R2 deletion, R3, R4, R5.
350350 - **Before a few thousand active workspaces:** R6, R7, R9, R10.
351+- **Before git over SSH ships:** SSH's git operations are metered (see "where the gap came
352+ from" under R1). Until then `crates/sshd` stays undeployed.
351353 - Everything else is resilience and exit planning, ideally done while the product is still invite-only.
352354
353355 ## 9. What was built (2026-10-06)
384386 node scripts/ops/artifacts-usage.mjs # last 31 days, a table
385387 node scripts/ops/artifacts-usage.mjs --days 7 --json > usage.json
386388 ARTIFACTS_NAMESPACE=g1t node scripts/ops/artifacts-usage.mjs
389+node scripts/ops/artifacts-usage.mjs --hours 2026-10-07 # one UTC day, hour by hour
387390 ```
388391
389392 It prints, per day, Cloudflare's `pull`, `push`, `create`, `fork` and `delete` events and its
400403 lost before they were written. Sandboxes are not it: every sandbox but a backup's clones,
401404 fetches and pushes through g1t's git endpoints (see "2026-10-07: where the gap came from").
402405 - Only days after the meters were deployed compare; before that only `git_operations` exists.
406+- A fix that lands mid-day is judged with `--hours DAY`: Cloudflare's operations and errors
407+ against `git_operations` hour by hour, then the day's errors by message and repository.
403408 - Binding calls do appear: Cloudflare's events include `read` and `token_create` actions (and
404409 `namespace_*`) besides the five documented ones. If Cloudflare says they are billed, map the
405410 `binding.*` meters in `operation_mapping`.
434439 read `cache.edge_hit` against `cache.miss` after a day; if the Cache API never hits from a
435440 Worker reached only by service bindings, put objects in KV instead. Still to do: caller
436441 attribution in the meters.
437−- 476 client errors on 2026-10-06 are unexplained; the fetch fix below accounts for some (every
438− 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.
439449
440450 **2026-10-07: where the gap came from.** Cloudflare counted 581 operations (pull 535, push 39,
441451 create 3, fork 4) against g1t's 458 (`git.fetch` 417, `git.receive_pack` 33, ...). The suspicion
450460 `git.info_refs`, `git.ls_refs`, `git.fetch` and `git.receive_pack`. Git an agent runs itself
451461 in its sandbox has the same remote and no other credential, so it is metered the same way.
452462 - `git_access` has no caller that is deployed: only `crates/sshd`, whose `/_internal/ssh/*`
453− endpoints do not exist yet. When git over SSH ships, its bridge talks to the store directly
454− and must report what it does (as backups do) or go through `git_http`.
463+ endpoints do not exist yet. **SSH must not ship until its git is metered.** Its bridge
464+ (`crates/sshd/src/git.rs`) talks to the store directly with the handed-out token, so nothing
465+ in `git_http` sees it: every SSH clone, fetch and push would be an operation Cloudflare bills
466+ and g1t never counts. Two ways to close it, either is enough:
467+ 1. Report, as backups do: when a session ends, sshd posts the service, the repo and the bytes
468+ each way to a repos RPC that records `git.info_refs` plus `git.fetch` or
469+ `git.receive_pack` against the repo's store key (`meters::record`, like `backups.rs`
470+ `meter_fetch`). A session that dies before reporting is lost, so count the operation when
471+ `git_access` hands out the token and add only the bytes from the report.
472+ 2. Send the bridge through `git_http` instead of the store, with the user's identity, so SSH
473+ is metered, cached and protected (branch and push protection) like HTTPS. This also closes
474+ M15 for SSH.
475+
476+ Building this is not small today: the Worker side (`/_internal/ssh/user` and
477+ `/_internal/ssh/access`) does not exist either, so it belongs with shipping SSH.
455478 - The one sandbox that reads the store directly is a nightly backup (`backups.rs`
456479 `store.handout`): its runner reports the clone with `fetched_bytes`, metered as
457480 `internal.git.info_refs` and `internal.git.backup_fetch` (g1t's cost, never a workspace's).
+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
+97−11
1111 //
1212 // CLOUDFLARE_API_TOKEN=<token with Account Analytics: Read> \
1313 // node scripts/ops/artifacts-usage.mjs [--days 31] [--json]
14+// node scripts/ops/artifacts-usage.mjs --hours 2026-10-07
1415 //
16+// --hours DAY shows one UTC day hour by hour (Cloudflare's operations and
17+// errors against `git_operations`), and the errors by message and repository:
18+// a fix that lands mid-day is judged on the hours after it.
19+//
1520 // The D1 queries run through Wrangler with the same environment (so the
1621 // token needs D1: Read too), or with CLOUDFLARE_D1_TOKEN when that is set,
1722 // or as you are logged in (`npx wrangler login`) when neither has it.
3237 };
3338 const days = Math.min(31, Math.max(1, Number(option("--days", "31")) || 31));
3439 const asJson = flag("--json");
40+const hoursOf = option("--hours", null);
41+if (hoursOf && !/^\d{4}-\d{2}-\d{2}$/.test(hoursOf)) {
42+ console.error("--hours takes a UTC day, YYYY-MM-DD");
43+ process.exit(2);
44+}
3545
3646 const auth = cloudflareAuth();
3747 if (!auth) {
4656 const start = new Date(end.getTime() - days * 24 * 3600 * 1000);
4757 const day = (date) => date.toISOString().slice(0, 10);
4858
59+async function graphql(query, variables) {
60+ const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
61+ method: "POST",
62+ headers: { ...auth, "content-type": "application/json", "user-agent": "g1t-ops" },
63+ body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, ...variables } }),
64+ });
65+ const body = await response.json();
66+ if (!response.ok || body.errors?.length) {
67+ throw new Error(`GraphQL: ${response.status} ${JSON.stringify(body.errors ?? body).slice(0, 600)}`);
68+ }
69+ return body.data?.viewer?.accounts?.[0] ?? {};
70+}
71+
72+/** One UTC day by the hour: Cloudflare's operations and errors against `git_operations`. */
73+async function hourly(dayText) {
74+ const from = `${dayText}T00:00:00Z`;
75+ const to = new Date(Date.parse(from) + 24 * 3600 * 1000).toISOString();
76+ const nsFilter = NAMESPACE ? `, repositoryNamespace: "${NAMESPACE.replace(/"/g, "")}"` : "";
77+ const query = `query ArtifactsHours($accountTag: String!, $start: Time!, $end: Time!) {
78+ viewer {
79+ accounts(filter: { accountTag: $accountTag }) {
80+ hours: artifactsEventsAdaptiveGroups(
81+ limit: 10000
82+ filter: { datetime_geq: $start, datetime_lt: $end${nsFilter} }
83+ orderBy: [datetimeHour_ASC]
84+ ) { count dimensions { datetimeHour eventKind eventType } }
85+ errors: artifactsEventsAdaptiveGroups(
86+ limit: 10000
87+ filter: { datetime_geq: $start, datetime_lt: $end, eventKind: "error"${nsFilter} }
88+ orderBy: [count_DESC]
89+ ) { count dimensions { eventType errorMessage repositoryName } }
90+ }
91+ }
92+ }`;
93+ const [account, operations] = await Promise.all([
94+ graphql(query, { start: from, end: to }),
95+ d1(
96+ `SELECT substr(hour, 12, 2) AS h, SUM(operations) AS operations FROM git_operations WHERE hour >= '${dayText}T00' AND hour <= '${dayText}T23' GROUP BY h`,
97+ ),
98+ ]);
99+ const ours = Object.fromEntries(operations.map((row) => [row.h, Number(row.operations)]));
100+ const types = ["pull", "push", "create", "fork", "delete"];
101+ const byHour = {};
102+ for (const group of account.hours ?? []) {
103+ const { datetimeHour, eventKind, eventType } = group.dimensions;
104+ const key = eventKind === "error" ? "errors" : eventType;
105+ if (key !== "errors" && !types.includes(key)) continue;
106+ const h = datetimeHour.slice(11, 13);
107+ (byHour[h] ??= {})[key] = (byHour[h][key] ?? 0) + group.count;
108+ }
109+ console.log(`Artifacts by the hour, ${dayText} UTC${NAMESPACE ? ` (namespace ${NAMESPACE})` : ""}\n`);
110+ console.log(["hour", ...types.map((t) => pad(`cf.${t}`, 9)), pad("cf.ops", 8), pad("g1t.ops", 8), pad("ratio", 6), pad("cf.errors", 10)].join(" "));
111+ let cfTotal = 0;
112+ let ourTotal = 0;
113+ for (let i = 0; i < 24; i++) {
114+ const h = String(i).padStart(2, "0");
115+ const cf = byHour[h] ?? {};
116+ const cfOps = types.reduce((total, t) => total + (cf[t] ?? 0), 0);
117+ const mine = ours[h] ?? 0;
118+ if (!cfOps && !mine && !cf.errors) continue;
119+ cfTotal += cfOps;
120+ ourTotal += mine;
121+ console.log(
122+ [h + " ", ...types.map((t) => pad(cf[t] ?? 0, 9)), pad(cfOps, 8), pad(mine, 8), pad(mine ? (cfOps / mine).toFixed(2) : "n/a", 6), pad(cf.errors ?? 0, 10)].join(" "),
123+ );
124+ }
125+ console.log(`\nday cf.ops ${cfTotal}, g1t.ops ${ourTotal}${ourTotal ? `, ratio ${(cfTotal / ourTotal).toFixed(2)}` : ""}`);
126+ const messages = {};
127+ const repositories = {};
128+ for (const group of account.errors ?? []) {
129+ const { eventType, errorMessage, repositoryName } = group.dimensions;
130+ const key = `${eventType}: ${errorMessage || "(no message)"}`;
131+ messages[key] = (messages[key] ?? 0) + group.count;
132+ repositories[repositoryName] = (repositories[repositoryName] ?? 0) + group.count;
133+ }
134+ console.log("\nErrors by message:");
135+ for (const [message, count] of Object.entries(messages).sort((a, b) => b[1] - a[1])) console.log(` ${pad(count, 6)} ${message}`);
136+ console.log("Errors by repository (top 8):");
137+ for (const [name, count] of Object.entries(repositories).sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log(` ${pad(count, 6)} ${name}`);
138+ console.log(
139+ "\ng1t.ops is what workspaces are counted for (billable meters only; nightly backups are g1t's own and not in it). An hour can straddle the two sides of a write by a few seconds.",
140+ );
141+}
142+
49143 /** Cloudflare's own count, by day, event kind and type (and namespace). */
50144 async function cloudflare() {
51145 const query = `query ArtifactsUsage($accountTag: String!, $start: Time!, $end: Time!) {
63157 }
64158 }
65159 }`;
66− const response = await fetch("https://api.cloudflare.com/client/v4/graphql", {
67− method: "POST",
68− headers: { ...auth, "content-type": "application/json" },
69− body: JSON.stringify({ query, variables: { accountTag: ACCOUNT_ID, start: start.toISOString(), end: end.toISOString() } }),
70− });
71− const body = await response.json();
72− if (!response.ok || body.errors?.length) {
73− throw new Error(`GraphQL: ${response.status} ${JSON.stringify(body.errors ?? body).slice(0, 600)}`);
74− }
75− const groups = body.data?.viewer?.accounts?.[0]?.artifactsEventsAdaptiveGroups ?? [];
160+ const account = await graphql(query, { start: start.toISOString(), end: end.toISOString() });
161+ const groups = account.artifactsEventsAdaptiveGroups ?? [];
76162 return groups
77163 .filter((group) => !NAMESPACE || group.dimensions.repositoryNamespace === NAMESPACE)
78164 .map((group) => ({
199285 );
200286 }
201287
202−main().catch((error) => {
288+(hoursOf ? hourly(hoursOf) : main()).catch((error) => {
203289 console.error(error.message);
204290 process.exit(1);
205291 });
+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);