> ## Documentation Index
> Fetch the complete documentation index at: https://getlago.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloudflare AI Gateway

> Bill LLM usage routed through Cloudflare AI Gateway. Live instrumentation on the wrapped client, or backfill from the gateway's own Logs API.

Cloudflare AI Gateway is a proxy, not a provider. It sits in front of OpenAI, Anthropic, Mistral, Gemini, and Cloudflare's own Workers AI, and it caches, logs, and meters everything passing through.

That changes two things for billing. Some responses never reach the provider at all, so they cost nothing and must not be billed. And Cloudflare already meters the real cost of every call it forwards, which is more accurate than any price a third party can compute.

The SDK handles both, through two paths you can use separately or together:

<CardGroup cols={2}>
  <Card title="Live path" icon="bolt">
    Point your existing wrapped client at the gateway. Bills as calls happen, and skips Cloudflare's own cache hits on OpenAI and Anthropic.
  </Card>

  <Card title="Backfill path" icon="clock-rotate-left">
    Poll the gateway's Logs API and bill Cloudflare's own metered cost. Pass each entry's id and re-running a window does not double-bill.
  </Card>
</CardGroup>

## Live path

There is no Cloudflare-specific client to wrap. You wrap the same provider client as always, pointed at your gateway's base URL instead of the provider's.

<CodeGroup>
  ```python Python theme={"dark"}
  from anthropic import Anthropic
  from lago_agent_sdk import LagoSDK

  sdk = LagoSDK(api_key="<YOUR_LAGO_API_KEY>", default_subscription_id="sub_acme")

  client = sdk.wrap(Anthropic(
      api_key="<YOUR_ANTHROPIC_API_KEY>",
      base_url=f"https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/anthropic",
      default_headers={"cf-aig-authorization": f"Bearer {gateway_auth}"},
  ))

  client.messages.create(
      model="claude-sonnet-4-6",
      max_tokens=200,
      messages=[{"role": "user", "content": "Hello"}],
  )
  sdk.flush()
  ```

  ```typescript TypeScript theme={"dark"}
  import Anthropic from "@anthropic-ai/sdk";
  import { LagoSDK } from "lago-agent-sdk";

  const sdk = new LagoSDK({
    apiKey: process.env.LAGO_API_KEY!,
    defaultSubscriptionId: "sub_acme",
  });

  const client = sdk.wrap(new Anthropic({
    apiKey: process.env.ANTHROPIC_API_KEY!,
    baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/anthropic`,
    defaultHeaders: { "cf-aig-authorization": `Bearer ${gatewayAuth}` },
  }));

  await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 200,
    messages: [{ role: "user", content: "Hello" }],
  });
  await sdk.flush();
  ```
</CodeGroup>

Everything on the [Anthropic](/docs/guide/ai-agents/agent-sdk/anthropic), [OpenAI](/docs/guide/ai-agents/agent-sdk/openai), [Gemini](/docs/guide/ai-agents/agent-sdk/gemini), and [Mistral](/docs/guide/ai-agents/agent-sdk/mistral) pages still applies. Two behaviors are layered on top.

<Note>
  Some provider SDKs take the gateway auth header per call rather than at construction. The Mistral client, for example, wants `http_headers={"cf-aig-authorization": ...}` on `chat.complete`, and takes `server_url` rather than `base_url`.
</Note>

### Gateway cache hits are not billed, on two of the five clients

When the gateway serves a response from its own cache, it sets `cf-aig-cache-status: HIT` and never calls the provider. Nothing was spent, so the SDK emits nothing for that response. This is implemented in the OpenAI and Anthropic wrappers only, and only for non-streaming calls.

Reading that header means going through the provider SDK's raw-response accessor rather than the plain create call. In Python, `.with_raw_response.create(...)` then `.parse()`, which returns the identical object, so nothing downstream changes. With no gateway in the path the header is simply absent, making this a no-op on direct provider calls.

Streaming is excluded deliberately: it would need `.with_streaming_response`, which behaves differently and is not verified end to end. See [Known limits](#known-limits) for the full per-client picture.

### Workers AI is priced from Cloudflare's own catalog

Workers AI is Cloudflare's own inference, reached through the gateway's OpenAI-compatible `/compat` endpoint. Wrap an OpenAI-shaped client at that endpoint and use `workers-ai/@cf/...` model ids:

<CodeGroup>
  ```python Python theme={"dark"}
  from openai import OpenAI

  client = sdk.wrap(OpenAI(
      api_key=gateway_auth,
      base_url=f"https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/compat",
  ))

  resp = client.chat.completions.create(
      model="workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast",
      messages=[{"role": "user", "content": "Hello"}],
  )
  ```

  ```typescript TypeScript theme={"dark"}
  import OpenAI from "openai";

  const client = sdk.wrap(new OpenAI({
    apiKey: gatewayAuth,
    baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/compat`,
  }));

  await client.chat.completions.create({
    model: "workers-ai/@cf/meta/llama-3.3-70b-instruct-fp8-fast",
    messages: [{ role: "user", content: "Hello" }],
  });
  ```
</CodeGroup>

An OpenAI-shaped client can point at real OpenAI or at Workers AI, and the client type alone cannot tell you which. The SDK resolves it from the model string the response reports: an id starting with `@cf/` is Workers AI's naming convention and never a real OpenAI model, so those events are stamped `provider: "workers-ai"` and priced from Cloudflare's catalog rather than OpenRouter.

That distinction is worth real money, not just correctness. Cloudflare's catalog is the rate the gateway actually bills at. OpenRouter lists other hosts' prices for the same open-weight models, and on a live check its figure for one model came out around 3.5x lower than what Cloudflare charged. Pricing Workers AI off OpenRouter would not be a naming mismatch, it would be the wrong number.

<Note>
  **Workers AI pricing needs credentials.** OpenRouter and the AWS price list are public. Cloudflare's model catalog is not, so price mode needs a Cloudflare account id and an API token:

  <CodeGroup>
    ```python Python theme={"dark"}
    LagoConfig(
        api_key="...",
        pricing_mode="price",
        cloudflare_account_id="<CF_ACCOUNT_ID>",
        cloudflare_api_token="<CF_API_TOKEN>",
    )
    ```

    ```typescript TypeScript theme={"dark"}
    new LagoSDK({
      apiKey: "...",
      config: {
        pricingMode: "price",
        cloudflareAccountId: "<CF_ACCOUNT_ID>",
        cloudflareApiToken: "<CF_API_TOKEN>",
      },
    });
    ```
  </CodeGroup>

  Without both set, Workers AI prices are unavailable and the SDK falls back to token-count events, the same as any other price miss.

  The catalog is read for three units: input tokens, output tokens, and cached input tokens. A Workers AI model priced per image or per audio-minute, or with no price published at all, is absent from the table and falls back to token events too.
</Note>

The catalog fetch is primed the moment you call `wrap()` on a client pointed at `gateway.ai.cloudflare.com`, not at SDK init, and it runs on the background thread. `wrap()` normally happens well before your first completion, so the table is usually warm before it lands.

## Backfill path

For usage that already happened, do not replay calls. Read the gateway's own logs. Cloudflare reports a real metered `cost` per entry, so there is no price lookup on our side at all.

This lives in a separate namespace from the wrapper adapters, because there is no client to instrument here. It is meant to run as a poller.

<CodeGroup>
  ```python Python theme={"dark"}
  from lago_agent_sdk.gateway.adapters import extract_cloudflare_log, resolve_subscription

  for entry in fetch_gateway_logs():   # GET .../ai-gateway/gateways/{id}/logs
      usage = extract_cloudflare_log(entry)
      sub = resolve_subscription(entry) or "sub_default"
      sdk.emit(
          usage,
          subscription=sub,
          mode="price",
          usd_cost=entry.get("cost") or 0,      # Cloudflare's own metered price
          event_id=f"cf_{sub}_{entry['id']}",   # idempotency key
      )

  sdk.flush()
  ```

  ```typescript TypeScript theme={"dark"}
  import { extractCloudflareLog, resolveSubscription } from "lago-agent-sdk/gateway/adapters";

  for (const entry of await fetchGatewayLogs()) {
    const usage = extractCloudflareLog(entry);
    const sub = resolveSubscription(entry) ?? "sub_default";
    sdk.emit(usage, {
      subscription: sub,
      mode: "price",
      usdCost: entry.cost ?? 0,              // Cloudflare's own metered price
      eventId: `cf_${sub}_${entry.id}`,      // idempotency key
    });
  }

  await sdk.flush();
  ```
</CodeGroup>

The logs endpoint is `GET https://api.cloudflare.com/client/v4/accounts/{account_id}/ai-gateway/gateways/{gateway_id}/logs`, paginated, authenticated with a Cloudflare API token. The list and single-entry endpoints return the same entry shape.

Because Cloudflare hands over one lump sum per call rather than a per-token-type split, this path emits a **single** `llm_cost` event with no `token_type`, and `unit` set to input plus output tokens. Splitting that lump proportionally would substitute a guess for the exact number you came here for. Live-path price mode, which does have per-token unit prices, emits one event per priced type instead. Both work with the same `sum_agg` metric on `field_name: "unit"` and a dynamic charge.

<Note>
  **You do not need to filter cache hits out.** Cloudflare reports a gateway cache hit with `tokens_in: 0`, `tokens_out: 0`, and `cost: 0`, so a cached entry extracts as zero usage and bills zero on its own. Billing policy never has to branch on the `cached` flag. The flag is exposed in `extras` for your own reporting, not because the maths needs it.
</Note>

### Two new `emit()` arguments

The backfill path is built on two arguments that price mode gained for exactly this case:

| Argument        | Python     | TypeScript | What it does                                                                                                                                                                                                                                   |
| --------------- | ---------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cost override   | `usd_cost` | `usdCost`  | Bill this exact amount and skip the SDK's own price lookup. Only consulted when the effective mode is `price`.                                                                                                                                 |
| Idempotency key | `event_id` | `eventId`  | Use this as Lago's `transaction_id` instead of a random UUID, so re-running the same window never double-bills. In token mode, which emits several events per call, each field's event is suffixed with the field name so they do not collide. |

<Warning>
  **Scope the idempotency key by subscription.** `transaction_id` is unique across your whole Lago organization, not per subscription. If you backfill a window onto one subscription and later re-run it onto a different one, a bare `cf_{entry_id}` key collides with the ids already used and the events silently go nowhere.

  Always include the subscription in the key, as in `f"cf_{sub}_{entry['id']}"`.
</Warning>

<Tip>
  **Unified or per-call attribution.** If all of a gateway's traffic belongs to one customer, bill every entry to a single subscription and ignore per-call attribution. If one gateway serves several customers, use `resolve_subscription(entry)` and fall back to a default only for unattributed entries. Vary the `event_id` prefix between the two strategies so a switch does not collide with keys already spent.
</Tip>

### Attributing a log entry to a subscription

`resolve_subscription()` reads `lago_subscription` out of the entry's `metadata`, which is populated from the `cf-aig-metadata` header on the original request. Set that header at call time and every log entry carries its own Lago subscription:

```http theme={"dark"}
cf-aig-metadata: {"lago_subscription": "sub_acme"}
```

It returns `None` when the header was never set. Deciding what to do with an unattributed entry (drop it, warn, bill a default) is left to you, deliberately.

## What gets captured

From a Logs API entry:

| Canonical field | Cloudflare log source                                  |
| --------------- | ------------------------------------------------------ |
| `input`         | `tokens_in`                                            |
| `output`        | `tokens_out`                                           |
| `cache_read`    | `usage_metadata.input_cached_tokens`                   |
| `cache_write`   | `usage_metadata.input_cache_creation_tokens`           |
| `reasoning`     | `usage_metadata.reasoningTokens` or `reasoning_tokens` |
| `model`         | `model`, passed straight through                       |
| `provider`      | `provider`, normalized onto the SDK's own vocabulary   |

Events are tagged `api: "cloudflare_gateway"`. The entry's `cached` flag, `step`, and `id` land in `CanonicalUsage.extras` (as `cached`, `step`, and `log_id`), because the poller needs them: `cached` to decide whether Cloudflare served the request for free, `log_id` as the replay key.

Cloudflare names some providers differently from the SDK, so a few are mapped on the way in: `google-ai-studio`, `google-vertex-ai`, and `vertex` all become `gemini`; `azure-openai` and `azureopenai` become `openai`; `workersai` becomes `workers-ai`. Anything unrecognized passes through unchanged, which means it will miss on price and fall back to token events rather than being billed against the wrong rate card. Bedrock is deliberately left unmapped for that reason.

<Note>
  Unlike the provider-native adapters, this one never has to guess which model actually served a request. A Cloudflare log entry always reports the resolved model, so it is immune by construction to the alias-versus-snapshot mismatch that affects request-side model ids.
</Note>

Malformed or missing fields degrade to zero rather than raising. One bad entry in a batch does not take down a whole poller run.

## Choosing a path

|                              | Live path                                                        | Backfill path                                                                                         |
| ---------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| When it bills                | As the call happens                                              | On the next poll                                                                                      |
| Price source                 | OpenRouter, AWS, or Cloudflare's catalog. The SDK's own estimate | Cloudflare's own metered `cost`, with one gap on reasoning tokens (see [Known limits](#known-limits)) |
| Cache hits                   | Skipped on OpenAI and Anthropic clients, non-streaming only      | Zero tokens and zero cost on the entry, so they bill zero                                             |
| Streaming                    | Billed as normal calls, cache hits included                      | Same as any other entry                                                                               |
| Re-runnable                  | No, one shot per call                                            | Yes, idempotent on `event_id`                                                                         |
| Needs Cloudflare credentials | Only for Workers AI pricing                                      | Yes, a Logs API token                                                                                 |

<Note>
  Only the backfill path bills Cloudflare's metered cost. The live path never sees it: no response header carries a per-call cost, so price mode there works exactly as it does for a direct provider call, off the public rate cards.
</Note>

You can run both, but only over **different traffic**. The two paths share no idempotency key: a live event gets a random `transaction_id`, a backfilled one gets the `event_id` you pass. Lago treats them as unrelated events and bills the same call twice. Use the live path for current traffic and the backfill path only for windows the live path never covered.

## Known limits

**Cache-hit skipping covers OpenAI and Anthropic clients only, and only when not streaming.** Reading `cf-aig-cache-status` means going through the provider SDK's raw-response accessor, which the streaming path does not expose.

| Client                                  | Non-streaming           | Streaming               |
| --------------------------------------- | ----------------------- | ----------------------- |
| OpenAI (Chat Completions and Responses) | Cache hit skipped       | Billed as a normal call |
| Anthropic (`messages.create`)           | Cache hit skipped       | Billed as a normal call |
| Mistral, Gemini, Bedrock                | Billed as a normal call | Billed as a normal call |

An older or custom OpenAI client without `with_raw_response` also falls back to the plain path with no detection. If you rely on gateway caching for cost control, use the backfill path — it handles cache hits for every provider, streaming included, because Cloudflare logs them with zero tokens and zero cost.

**Cloudflare's metered `cost` excludes reasoning tokens.** It is exact on input, output, cache reads, and cache writes, but additive reasoning tokens are left out. On two measured thinking-heavy Gemini calls the reported cost came to roughly 4% of what Google actually charged — 22.8× and 39.6× under. For models that do not reason, the backfill figure is the one to bill on; for a thinking-heavy workload, meter it in token mode and price the reasoning tokens yourself.

**Cloudflare does not normalize `usage_metadata` key casing.** It passes through whatever convention the underlying provider used: Anthropic and OpenAI entries come back snake\_case (`input_cached_tokens`), while a captured Gemini entry used camelCase (`reasoningTokens`). The adapter checks both forms for every field it maps, but that is observed behavior across two providers, not a documented Cloudflare guarantee — a provider using a third convention could report tokens the adapter reads as zero. If you add a provider to your gateway, verify one log entry against the counter in Lago before trusting the rollup.

## Next steps

<CardGroup cols={2}>
  <Card title="Configuration reference" icon="sliders" href="/docs/guide/ai-agents/agent-sdk/reference">
    Every config knob, in both SDKs.
  </Card>

  <Card title="Bill in dollars" icon="dollar-sign" href="/docs/guide/ai-agents/agent-sdk/billing">
    How price mode and the `llm_cost` metric work.
  </Card>
</CardGroup>
