Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions docs/ai-gateway-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ turn, the overseer calls `checkUsageAndBalance`:
- **Connected, balance ≥ `$2`** → allowed, routed through the user's own account so usage bills
their Cloudflare credits — even while free-tier allowance remains. The platform is never charged
for funded users, and their daily free-tier counter is left untouched.
- **Otherwise, within the free tier** → allowed, served via the platform's configured AI Gateway.
Workers AI uses the same Gateway ID unless `CF_AI_GATEWAY_WAI_DIRECT=true` sends it straight to
the Workers AI REST endpoint or `CF_AI_GATEWAY_WAI` selects another Gateway. This includes
connected users whose balance is below `$2` (incl. $0).
- **Otherwise, within the free tier** → allowed, served via the platform's configured AI Gateway
(all providers, Workers AI included). This includes connected users whose balance is below `$2`
(incl. $0).
- **Free tier exhausted, no Cloudflare account connected** → blocked, with a prompt to connect.
- **Free tier exhausted, connected but balance below `$2`** → blocked, with a prompt to add credits.

Expand Down Expand Up @@ -54,19 +53,22 @@ CLOUDFLARE_OAUTH_CLIENT_SECRET=...
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set (all inference goes over HTTPS with tokens):
# Required whenever CF_AI_GATEWAY is set:
CF_AI_GATEWAY_ACCOUNT_ID=...
# Required unless the WORKERS_AI binding carries gateway traffic; always required for the
# google provider:
CF_AI_GATEWAY_API_TOKEN=...

# To send Workers AI straight to its REST endpoint (no gateway, no cost logs):
CF_AI_GATEWAY_WAI_DIRECT=true

@Maximo-Guk Maximo-Guk Aug 10, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's unclear to me why we introduced a CF_AI_GATEWAY_WAI_DIRECT env var in the first place, I don't see why we'd want workers-ai traffic to not go through the gateway. I think the CF_AI_GATEWAY_USE_BINDING=false makes a lot more sense, it opts out all the providers from flowing through the binding transport and with CF_AI_GATEWAY_ACCOUNT_ID allows you to use a cross account gateway for all providers

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this may have been an artifact of an early misunderstanding where I didn't realize AI Gateway was available via the Workers AI binding. Yeah I think we can remove.

```

Gateway mode always requires `CF_AI_GATEWAY_ACCOUNT_ID` and an API token with AI Gateway Run and
Read permissions; Read access lets Gadgets retrieve each log's cost for user-visible accounting.
Workers AI uses `CF_AI_GATEWAY` as its Gateway ID by default; set `CF_AI_GATEWAY_WAI` to select
another Gateway, or `CF_AI_GATEWAY_WAI_DIRECT=true` to call the Workers AI REST endpoint directly
(same credentials, no gateway cost logs).
Gateway mode always requires `CF_AI_GATEWAY_ACCOUNT_ID` plus a transport: the `WORKERS_AI`
binding when present (binding requests are pre-authenticated, and cost-log reads work through
the binding too), or otherwise an API token with AI Gateway Run and Read permissions — Read
access lets Gadgets retrieve each log's cost for user-visible accounting. The binding transport
only works when the Gateway lives in the Worker's own account, which the Worker can't verify at
runtime — a deployment whose Gateway is in a different account must set
`CF_AI_GATEWAY_USE_BINDING=false` to opt out and use the token transport. The token stays
required for the `google` provider even when the binding transport applies. Every provider,
Workers AI included, routes through the same Gateway.

The Cloudflare dashboard OAuth endpoints and scopes are **hardcoded** in the Cloudflare gatekeeper
(`packages/gatekeeper-cloudflare/src/oauth.ts`):
Expand Down
33 changes: 20 additions & 13 deletions docs/public-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,32 @@ CLOUDFLARE_OAUTH_CLIENT_SECRET=...
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set (all inference goes over HTTPS with tokens):
# Required whenever CF_AI_GATEWAY is set:
CF_AI_GATEWAY_ACCOUNT_ID=...
# Required unless the WORKERS_AI binding carries gateway traffic (see below); always required
# for the google provider:
CF_AI_GATEWAY_API_TOKEN=...

# To send Workers AI straight to its REST endpoint (no gateway, no cost logs):
CF_AI_GATEWAY_WAI_DIRECT=true
```

Gateway mode always requires `CF_AI_GATEWAY_ACCOUNT_ID` and `CF_AI_GATEWAY_API_TOKEN`; the token
needs AI Gateway Run and Read permissions so Gadgets can execute models and report their costs
(the Gateway may live in the Worker's own account or a different one). Workers AI defaults to the
same Gateway ID; set `CF_AI_GATEWAY_WAI` to route it through a different Gateway in the same
account, or `CF_AI_GATEWAY_WAI_DIRECT=true` to bypass gateways and call the Workers AI REST
endpoint directly (using the same account/token pair; such requests produce no cost logs).
Gateway mode always requires `CF_AI_GATEWAY_ACCOUNT_ID`, plus a transport for gateway requests.
When the `WORKERS_AI` binding is present, the binding is that transport by default: its requests
are pre-authenticated in-account, so inference and cost-log reads need no API token. This is only
valid when the Gateway lives in the Worker's **own** account — binding requests can't reach
another account's Gateway, and the Worker cannot verify where the Gateway lives at runtime — so
deployments whose Gateway is in a different account must set `CF_AI_GATEWAY_USE_BINDING=false` to
opt out and route over HTTPS instead. Without the binding transport, set
`CF_AI_GATEWAY_API_TOKEN` — a token with AI Gateway Run and Read permissions so Gadgets can
execute models and report their costs (over HTTPS the Gateway may live in the Worker's own
account or a different one). The token stays required for the `google` provider regardless of the
binding (its SDK can't ride the binding transport — note the platform config above enables it, so
the platform server itself still needs the token). Every provider, Workers AI included, routes
through the same Gateway.

When using `CF_AI_GATEWAY*` in local development, start the server with
`pnpm run dev-server -- --use-workers-ai-binding` so the webFetch tool's document-to-Markdown
conversion still has a `WORKERS_AI` binding. (Inference itself no longer uses the binding; it goes
over HTTPS with the tokens above.)
`pnpm run dev-server -- --use-workers-ai-binding` so the server has a `WORKERS_AI` binding for
the webFetch tool's document-to-Markdown conversion and for the gateway transport above (without
it, gateway traffic falls back to HTTPS with `CF_AI_GATEWAY_API_TOKEN`). If your dev Gateway
lives in a different account than the binding, also set `CF_AI_GATEWAY_USE_BINDING=false`.

Each gatekeeper's OAuth app must be registered with that gatekeeper's redirect URI (replace the host
with `PUBLIC_BASE_URL`):
Expand Down
91 changes: 91 additions & 0 deletions packages/workshop-backend/__tests__/ai-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AiGatewayConfig,
AiGatewayLogRetryableError,
getAiGatewayLogCost,
} from "../src/ai-gateway.js";
Expand All @@ -13,6 +14,96 @@ function env(overrides: Partial<Cloudflare.Env> = {}): Cloudflare.Env {
} as Cloudflare.Env;
}

describe("AiGatewayConfig transport selection", () => {
const binding = { gateway: () => ({}) } as unknown as Ai;
// google needs the HTTPS+token transport, so token-less configs must not enable it.
const bindingOnly = env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
CF_AI_GATEWAY_PROVIDERS: "anthropic,openai,cloudflare",
WORKERS_AI: binding,
});

it("uses the binding for every provider except google", () => {
const config = new AiGatewayConfig(bindingOnly);
expect(config.apiToken).toBeUndefined();
expect(config.bindingFor("anthropic")).toBe(binding);
expect(config.bindingFor("openai")).toBe(binding);
expect(config.bindingFor("cloudflare")).toBe(binding);
expect(config.bindingFor("google")).toBeUndefined();
});

it("falls back to HTTPS with the token when the binding is absent", () => {
const config = new AiGatewayConfig(env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
CF_AI_GATEWAY_API_TOKEN: "gateway-token",
WORKERS_AI: undefined,
}));
expect(config.apiToken).toBe("gateway-token");
expect(config.bindingFor("anthropic")).toBeUndefined();
});

it("ignores the binding when CF_AI_GATEWAY_USE_BINDING=false opts out", () => {
// The cross-account shape (e.g. the internal production Workshop): WORKERS_AI is injected
// for webFetch, but the gateway lives in a different account, so the deployment opts out
// and gateway traffic rides HTTPS with the token.
const config = new AiGatewayConfig(env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
CF_AI_GATEWAY_API_TOKEN: "gateway-token",
CF_AI_GATEWAY_USE_BINDING: "false",
WORKERS_AI: binding,
}));
expect(config.binding).toBeUndefined();
expect(config.apiToken).toBe("gateway-token");
expect(config.bindingFor("anthropic")).toBeUndefined();
expect(config.bindingFor("openai")).toBeUndefined();
});

it("still requires a transport when the opt-out leaves no token", () => {
expect(() => new AiGatewayConfig({
...bindingOnly,
CF_AI_GATEWAY_USE_BINDING: "false",
})).toThrow("AI Gateway mode needs a transport");
});

it("rejects an explicit CF_AI_GATEWAY_USE_BINDING=true without the WORKERS_AI binding", () => {
expect(() => new AiGatewayConfig(env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
CF_AI_GATEWAY_API_TOKEN: "gateway-token",
CF_AI_GATEWAY_USE_BINDING: "true",
WORKERS_AI: undefined,
}))).toThrow("CF_AI_GATEWAY_USE_BINDING requires the WORKERS_AI binding");
});

it("requires the account id", () => {
expect(() => new AiGatewayConfig(env({ CF_AI_GATEWAY_ACCOUNT_ID: undefined })))
.toThrow("CF_AI_GATEWAY_ACCOUNT_ID is required when CF_AI_GATEWAY is set.");
});

it("requires a transport", () => {
expect(() => new AiGatewayConfig(env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
WORKERS_AI: undefined,
}))).toThrow("AI Gateway mode needs a transport");
});

it("requires the token when google is enabled", () => {
expect(() => new AiGatewayConfig(env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
WORKERS_AI: binding,
}))).toThrow("enabling the google provider requires CF_AI_GATEWAY_API_TOKEN");
});

it("resolves the same-account gateway for binding-based callers (webFetch)", () => {
expect(new AiGatewayConfig(bindingOnly).sameAccountGateway).toBe("platform-gateway");
expect(new AiGatewayConfig(env({
CF_AI_GATEWAY_ACCOUNT_ID: "account-id",
CF_AI_GATEWAY_API_TOKEN: "gateway-token",
CF_AI_GATEWAY_USE_BINDING: "false",
WORKERS_AI: binding,
})).sameAccountGateway).toBeUndefined();
});
});

describe("getAiGatewayLogCost", () => {
afterEach(() => vi.unstubAllGlobals());

Expand Down
Loading
Loading