Skip to content
bits
Ship an agent to the edge · Lesson 2 of 8 · 20 min

The gateway earns its keep

Desk: best at a computer, with a terminal

Verified agents 0.33.0ai 7.0.162wrangler 4.157.0

Start from f-01 on GitHub. Starting here, or behind? Get the code at that tag:

Shell
git clone https://github.com/ryanyogan/klippy
cd klippy
git checkout f-01
Tutor:

Every model call Klippy makes already goes through your AI Gateway, default, since course A. So far it only routes and falls back. In this lesson it starts to earn its keep: it labels each call, so you can see who spent what, and it caches the one answer that's the same for everyone.

Tutor:

1. Label every call

model() now takes a Call: which part of Klippy is asking, and whose Klippy it is. One gateway() helper puts that on every request:

// What each call is for, so the gateway can label it (f-02). route: which part of Klippy asked; user: whose Klippy.
// cache: only for a request that's the same for everyone (the check probe). Chats are personal and change: never cached.
export type Call = { route: 'ask' | 'note' | 'chat' | 'check' | 'context'; user?: string; cache?: { key: string; ttl: number } };
// Every model call goes through your AI Gateway. "default" is created for you on first use.
function gateway(env: Env, { route, user, cache }: Call) {
return createAI({
binding: env.AI,
id: 'default',
// Shown on each request in the gateway's logs, and what spend limits can be scoped by (say, per user per day).
// Labels only: never the question, a note or a token.
metadata: { app: 'klippy', route, user: user ?? null },
// Caching is off unless a request opts in: cacheKey opts in, cacheTtl says for how long (60 s minimum).
...(cache ? { cacheKey: cache.key, cacheTtl: cache.ttl } : {}),
});
}
TypeScriptUTF-8at f-02● lines 12–14: apps/agent/src/model.ts
Tutor:

The metadata shows on each request in the gateway's logs. It also scopes spend limits: a dollar budget per model, provider or metadata value that blocks requests once it's spent. So a user label is what a per-user daily limit keys on. Claude and the fallback get the same labels, because a fallback model inherits the options of the chain it's part of.

Labels only: never the question, a note or a token. Note that the gateway's own log keeps each prompt and reply by default. For a personal Klippy that's your own data in your own account, but know it's there.

A chat turn says whose Klippy is talking (gh-<id> from the web app):

model: model(this.env, { route: 'chat', user: this.name }), // labelled with whose Klippy it is, in the gateway's logs
TypeScriptUTF-8at f-02
Tutor:

For that to compile, model() takes the Call (with /ask's as the default), and the ingest step's contextModel() labels itself:

export function model(env: Env, call: Call = { route: 'ask' }) {
const ai = gateway(env, call);
...
export function contextModel(env: Env) {
const ai = gateway(env, { route: 'context' });
TypeScriptUTF-8at f-02
Tutor:

ask() takes a Call too and passes it on, so the check below can label (and cache) its probe:

import { model, type Call } from './model';
...
export async function ask(env: Env, question: string, tools: ToolSet = allTools(env, question), call: Call = { route: 'ask' }) {
const { text, providerMetadata } = await generateText({
model: model(env, call),
TypeScriptUTF-8at f-02
Tutor:

And the note tidier says what it is:

model: model(env, { route: 'note' }),
TypeScriptUTF-8at f-02
Tutor:

2. Caching needs a key

The obvious way to cache is createAI({ cacheTtl: 300 }). On a new gateway, that caches nothing. AI Gateway's caching is off unless you turn it on, and cacheTtl only sets how long a cached answer lives for a request that already uses caching. What opts one request in is cacheKey. So Klippy sets both, and only for the check probe:

// The probe is the same for every caller, so the gateway may cache it too (f-02): a fresh isolate, or another
// datacenter, gets the cached answer for 5 minutes instead of a new model call. The key names the tag.
const probe = await ask(env, 'Say hello to bits in one short sentence.', {}, { route: 'check', cache: { key: `klippy-check-${TAG}`, ttl: 300 } });
TypeScriptUTF-8at f-02● lines 3–3: apps/agent/src/check.ts
Tutor:

Why only the probe? It's the same question for every caller, so a fresh isolate or another datacenter can reuse the answer for five minutes instead of a new model call. A chat is personal and changes by the minute (the time, your notes). With a wrong key, a cached chat reply could answer one person with another's words. Cache only what's the same for everyone, and put the tag in the key, so a new version never serves an old answer.

ask() returns the gateway's cache status too:

// cache: HIT when the gateway answered from its cache (only requests that opt in, like the check probe).
return { answer, model: providerMetadata?.cloudflare?.model, cache: providerMetadata?.cloudflare?.cacheStatus };
TypeScriptUTF-8at f-02
Tutor:

Copy test/gateway.spec.ts. It swaps env.AI for a stand-in that records what it's asked, and checks that a chat carries its labels on both legs and no cache key, while the probe carries cacheKey and cacheTtl. No model or network is called. Ours: 22 test files, 98 tests passed.

Tutor:

3. Watch it hit

Three calls to the check, on wrangler dev (the Workers AI fallback answered, not Claude):

recorded output
curl -s 'localhost:8801/klippy/check?nonce=f02-first'
{"nonce":"f02-first","tag":"e-09","probe":{"answer":"Hello, little bits!","model":"@cf/zai-org/glm-4.7-flash","cache":"MISS"}}
Tutor:

Calling it again on the same server says MISS too, but makes no model call: the module's own five-minute cache answered. Then we stopped the server and started a new one, with an empty module cache:

recorded output
curl -s 'localhost:8801/klippy/check?nonce=f02-fresh'
{"nonce":"f02-fresh","tag":"e-09","probe":{"answer":"Hello, little bits!","model":"@cf/zai-org/glm-4.7-flash","cache":"HIT"}}
Tutor:

The same words, from the gateway's cache. (The tag still says e-09: it changes at the checkpoint.)

Tutor:

4. Set a spend limit

This one is in the dashboard, not the code: select the default gateway, then Settings → Spend limits. A rule is a dollar budget over a time window. Before each request, the gateway checks every rule, and once one is spent it answers 429 instead of calling the provider. Add one for the whole gateway, and one by the user metadata, split by value, so each Klippy gets its own budget. Know its limits, from Cloudflare's docs: a request's cost is recorded after it completes, so a burst of requests at once can go briefly over. Cost is a best-effort estimate from token counts. And only models with known pricing count. Budget alerts under Billing also email you, but they fire the next day, so they're no brake. We haven't set limits on the recording account. Do it before you deploy.

quick check+20 neurons
Tutor:

You add createAI({ cacheTtl: 300 }) to a fresh gateway and call the same prompt twice. What happens?

    Pick one option

    Pick one, then Check.