Skip to content
AI Full-Stack Wiki

Tutorial: Connect a Real Model Through a Guarded, Budgeted Gateway

Replace the fake model in the secure streaming API with Claude through one gateway that holds the key, retries, costs every call, and enforces daily budgets.

Difficulty: Intermediate26 min readUpdated
On this page· What you will build

Why this matters

In Foundations Tutorial 5 you built an API with sessions, API keys, rate limits, and streaming, but the "model" was a function that read your prompt back. This tutorial connects the real thing, the way API keys, billing, and security recommends: one gateway function is the only code that holds the key and calls the model, and it costs, records, and budgets every call. You will also build a mock of the Claude API, so the tests and the CI pipeline from Foundations Tutorial 6 keep running without a key and without spending money.

What you will build#

  • lib/pricing.ts: the price table and a costUSD function, including cache tokens and Haiku's long-prompt rate.
  • lib/ledger.ts: per-user daily spend and a log of every call.
  • lib/llm.ts: generateForUser, the only function that calls Claude: budget check, streaming, timeouts and retries, cost, ledger, and one structured log line.
  • /api/generate: now streams real model output, with clear errors for budgets, rate limits, and overloads.
  • /api/usage: what the caller has spent today.
  • scripts/mock-claude.ts: a local stand-in for the streaming API, for development, tests, and CI.
Guarded model gatewayA generate request is authenticated and rate-limited, checked against the caller's daily dollar budget, and only then streamed from Claude through one gateway function, which costs the call from its usage and records it in a ledger.ClientGenerate routeauth, rate limitBudgetdollars per daygenerateForUserthe only callerClaude APIor the mockLedgerspend + log

Step 1 of 4

Who and how often

The route from Tutorial 5 authenticates the caller and applies the per-minute rate limit.

Guarded model gateway: requests pass authentication, rate limits, and a daily dollar budget before one gateway function streams from Claude and records the cost of every call in a ledger.
Show text description

The key, the budget, and the cost accounting live in one place, so a new endpoint cannot call the model without them.

  • Client
  • Generate route: The Tutorial 5 route: session or API key, requests per minute, Zod validation.
  • Budget: Refuses with 429 once the user's spend for the UTC day reaches DAILY_BUDGET_USD.
  • generateForUser: Holds the API key, streams from Claude with a timeout and retries, and costs every call, including stopped ones.
  • Claude API: The real API, or the local mock server when ANTHROPIC_BASE_URL points to it.
  • Ledger: Per-user daily spend in micro-dollars and a log entry per call, served by /api/usage.
  1. Who and how often. The route from Tutorial 5 authenticates the caller and applies the per-minute rate limit.
  2. Within budget?. Before any model call, today's spend is compared with the daily budget. Over it means 429 until midnight UTC.
  3. Stream from Claude. The gateway streams the reply. The SDK retries rate limits and overload errors; Stop aborts the model request too.
  4. Cost and record. Usage becomes dollars with the price table, the ledger adds it to today's spend, and one structured log line is written.

You need the finished Foundations Tutorial 6 project (Tutorial 5's API, with its tests, env validation, and CI). An Anthropic API key is optional: everything up to step 9 works against the mock.

1. Install the SDK and configure the environment#

npm install @anthropic-ai/sdk

The server now needs a model key, and two new settings that are worth validating at startup like everything else:

lib/env.ts
import { z } from "zod";
import { MODELS } from "./pricing.ts";
 
const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  SESSION_SECRET: z.string().min(32, "must be at least 32 random characters"),
  GIT_SHA: z.string().default("dev"),
  ANTHROPIC_API_KEY: z.string().startsWith("sk-ant-", "must be an Anthropic API key (sk-ant-...)"),
  // Only models with a price in lib/pricing.ts are allowed, so every call can be costed.
  CLAUDE_MODEL: z.enum(MODELS).default("claude-opus-5-5"),
  DAILY_BUDGET_USD: z.coerce.number().positive().default(0.5),
});
 
export type Env = z.infer<typeof Env>;
 
/** Validate the environment, listing every problem at once. */
export function parseEnv(source: Record<string, string | undefined>): Env {
  const parsed = Env.safeParse(source);
  if (!parsed.success) {
    const problems = parsed.error.issues.map((i) => `  ${i.path.map(String).join(".")}: ${i.message}`);
    throw new Error(`Invalid environment variables:\n${problems.join("\n")}`);
  }
  return parsed.data;
}

CLAUDE_MODEL is an enum built from the price table. Configure a model you have not priced and the server refuses to start, instead of silently recording every call as free.

Add the key to .env.local. For now, also point the SDK at the mock you will write in step 4:

.env.local
SESSION_SECRET=...                     # from Tutorial 5
ANTHROPIC_API_KEY=sk-ant-...           # your key, or any sk-ant- value while using the mock
ANTHROPIC_BASE_URL=http://localhost:4010

The SDK reads both ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL from the environment, so switching between the mock and the real API is a configuration change, not a code change.

2. Price every call#

The billing article introduced costUSD. This version adds Claude Haiku 5.5, whose price depends on prompt size: one rate card up to 100K prompt tokens and a higher one above.

lib/pricing.ts
import type Anthropic from "@anthropic-ai/sdk";
 
// USD per million tokens at the time of writing. The one place to update when prices change.
// A model can have several rate cards, chosen by prompt size (input + cache tokens).
const PRICES = {
  "claude-opus-5-5": [{ upTo: Infinity, input: 4, output: 20, cacheRead: 0.2 }],
  "claude-sonnet-5-5": [{ upTo: Infinity, input: 2, output: 10, cacheRead: 0.2 }],
  "claude-haiku-5-5": [
    { upTo: 100_000, input: 0.1, output: 0.5, cacheRead: 0.01 },
    { upTo: Infinity, input: 0.5, output: 2.5, cacheRead: 0.05 },
  ],
} as const;
 
export type Model = keyof typeof PRICES;
export const MODELS = Object.keys(PRICES) as [Model, ...Model[]];
 
const CACHE_WRITE_MULTIPLIER = 1.25; // 5-minute cache writes cost 1.25x the input price
 
type BilledUsage = Pick<Anthropic.Usage, "input_tokens" | "output_tokens" | "cache_creation_input_tokens" | "cache_read_input_tokens">;
 
/** Dollar cost of one response. `input_tokens` excludes cached tokens; thinking is inside `output_tokens`. */
export function costUSD(model: Model, usage: BilledUsage): number {
  const cacheWrite = usage.cache_creation_input_tokens ?? 0;
  const cacheRead = usage.cache_read_input_tokens ?? 0;
  const promptTokens = usage.input_tokens + cacheWrite + cacheRead;
  const rate = PRICES[model].find((r) => promptTokens <= r.upTo)!;
  return (
    (usage.input_tokens * rate.input +
      cacheWrite * rate.input * CACHE_WRITE_MULTIPLIER +
      cacheRead * rate.cacheRead +
      usage.output_tokens * rate.output) /
    1_000_000
  );
}

The rate card is chosen by the whole prompt, cached tokens included, because input_tokens alone excludes what was read from or written to the cache.

3. A ledger of spend#

lib/ledger.ts
import type { Model } from "./pricing.ts";
 
export interface LedgerEntry {
  at: Date;
  userId: string;
  requestId: string;
  model: Model;
  inputTokens: number;
  outputTokens: number;
  usd: number;
  outcome: "completed" | "stopped" | "failed";
}
 
// In memory, so per process and lost on restart, like the rate limiter. With more than one
// instance, keep the daily counters in Redis (see the API keys and billing article) and the
// entries in Postgres.
const spentMicros = new Map<string, number>(); // "userId:YYYY-MM-DD" -> micro-dollars, an integer
const entries: LedgerEntry[] = [];
 
const dayKey = (userId: string, at: Date) => `${userId}:${at.toISOString().slice(0, 10)}`; // UTC day
 
export function spentTodayUSD(userId: string, now = new Date()): number {
  return (spentMicros.get(dayKey(userId, now)) ?? 0) / 1_000_000;
}
 
export function record(entry: LedgerEntry) {
  const key = dayKey(entry.userId, entry.at);
  spentMicros.set(key, (spentMicros.get(key) ?? 0) + Math.round(entry.usd * 1_000_000));
  entries.push(entry);
  if (entries.length > 10_000) entries.shift();
}
 
export function recentFor(userId: string, limit = 10): LedgerEntry[] {
  return entries.filter((e) => e.userId === userId).slice(-limit).reverse();
}

Spend is kept in micro-dollars as integers. Adding thousands of fractions like 0.000804 in floating point drifts; integers do not.

4. A mock Claude API#

Before writing the gateway, build the thing that lets you test it for free. The mock speaks the same streaming event format as the Messages API (message_start, content_block_delta, message_delta, message_stop), so the real SDK parses it as if it were the real service. It can also fail on demand with 429 (rate limited) or 529 (overloaded).

scripts/mock-claude.ts
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
 
/**
 * A tiny stand-in for the Messages API's streaming format, for offline development, tests, and CI.
 * Point the SDK at it with ANTHROPIC_BASE_URL. Answers and token counts are fake.
 */
export async function startMockClaude(port = 4010) {
  let failures = { left: 0, status: 429 };
 
  const server = createServer(async (req, res) => {
    let raw = "";
    for await (const chunk of req) raw += chunk;
    if (req.method !== "POST" || req.url !== "/v1/messages") {
      res.writeHead(404, { "content-type": "application/json" });
      return res.end(JSON.stringify({ type: "error", error: { type: "not_found_error", message: "Not found" } }));
    }
    if (failures.left > 0) {
      failures.left--;
      const type = failures.status === 529 ? "overloaded_error" : "rate_limit_error";
      res.writeHead(failures.status, { "content-type": "application/json", "retry-after": "0" });
      return res.end(JSON.stringify({ type: "error", error: { type, message: `Mock ${type}` } }));
    }
 
    const body = JSON.parse(raw) as { model: string; messages: { content: string }[] };
    const prompt = String(body.messages.at(-1)?.content ?? "");
    const words = `Mock reply to "${prompt.slice(0, 60)}". Unset ANTHROPIC_BASE_URL to talk to the real API.`.split(" ");
    const usage = { input_tokens: Math.ceil(prompt.length / 4) + 20, output_tokens: words.length * 2 };
 
    res.writeHead(200, { "content-type": "text/event-stream" });
    const send = (type: string, data: object) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
    send("message_start", {
      message: { id: "msg_mock", type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, stop_sequence: null, usage: { ...usage, output_tokens: 1 } },
    });
    send("content_block_start", { index: 0, content_block: { type: "text", text: "" } });
    for (const [i, word] of words.entries()) {
      if (res.destroyed) return;
      send("content_block_delta", { index: 0, delta: { type: "text_delta", text: `${i ? " " : ""}${word}` } });
      await sleep(20);
    }
    send("content_block_stop", { index: 0 });
    send("message_delta", { delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: usage.output_tokens } });
    send("message_stop", {});
    res.end();
  });
 
  await new Promise<void>((resolve) => server.listen(port, resolve));
  return {
    url: `http://localhost:${(server.address() as AddressInfo).port}`,
    /** Answer the next `count` requests with 429 (rate limited) or 529 (overloaded). */
    failNext(count: number, status: 429 | 529 = 429) {
      failures = { left: count, status };
    },
    close: () => new Promise<void>((resolve) => server.close(() => resolve())),
  };
}
 
// Run directly: node scripts/mock-claude.ts
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const mock = await startMockClaude(Number(process.env.PORT ?? 4010));
  console.log(`mock Claude API on ${mock.url}`);
}

A mock tests your code: the gateway's accounting, the route's error handling, the SDK integration. It says nothing about answer quality. That is what the benchmark harness and real evals are for.

5. The gateway#

This is the heart of the tutorial: the one function that calls the model.

lib/llm.ts
import Anthropic from "@anthropic-ai/sdk";
import { type Env, parseEnv } from "./env.ts";
import { type LedgerEntry, record, spentTodayUSD } from "./ledger.ts";
import { costUSD } from "./pricing.ts";
 
// Reads ANTHROPIC_API_KEY (and ANTHROPIC_BASE_URL, if set) from the server environment.
// One client per process. The key never leaves this module: not in responses, not in logs.
const client = new Anthropic({
  timeout: 60_000, // milliseconds; finite, so a request cannot hang forever
  maxRetries: 2, // automatic retries with backoff on 408, 409, 429, 5xx, and connection errors
});
 
// Validated on first use, at request time, never during `next build`.
let settings: Env | undefined;
const env = () => (settings ??= parseEnv(process.env));
 
const SYSTEM = "You are a concise assistant. Answer in at most three short paragraphs.";
 
export class BudgetExceededError extends Error {}
 
export function budgetFor(userId: string) {
  const limitUSD = env().DAILY_BUDGET_USD;
  const spentUSD = spentTodayUSD(userId);
  return { limitUSD, spentUSD, remainingUSD: Math.max(0, limitUSD - spentUSD) };
}
 
export type GenerationEvent = { type: "text"; text: string } | { type: "done"; entry: LedgerEntry; stopReason: string | null };
 
/**
 * The only function in the app that calls the model. Every call is budget-checked,
 * costed, recorded, and logged here, so no route can forget to.
 */
export async function* generateForUser(userId: string, prompt: string, signal: AbortSignal): AsyncGenerator<GenerationEvent> {
  if (budgetFor(userId).remainingUSD <= 0) throw new BudgetExceededError("Daily budget reached");
 
  const model = env().CLAUDE_MODEL;
  const requestId = crypto.randomUUID();
  const stream = client.messages.stream(
    { model, max_tokens: 1024, output_config: { effort: "low" }, system: SYSTEM, messages: [{ role: "user", content: prompt }] },
    { signal }, // the user pressing Stop cancels the request to the model, too
  );
 
  let outcome: LedgerEntry["outcome"] = "failed";
  let final: Anthropic.Message | undefined;
  let entry: LedgerEntry | undefined;
  try {
    for await (const event of stream) {
      if (event.type === "content_block_delta" && event.delta.type === "text_delta") yield { type: "text", text: event.delta.text };
    }
    final = await stream.finalMessage();
    outcome = "completed";
  } catch (error) {
    if (signal.aborted) outcome = "stopped";
    throw error;
  } finally {
    // Stopped or failed mid-stream, the tokens generated so far are still billed, so record those too.
    // The in-progress snapshot only has exact output tokens once the stream ends; for a stopped request it undercounts.
    const usage = (final ?? stream.currentMessage)?.usage;
    if (usage) {
      entry = {
        at: new Date(),
        userId,
        requestId,
        model,
        inputTokens: usage.input_tokens,
        outputTokens: usage.output_tokens,
        usd: costUSD(model, usage),
        outcome,
      };
      record(entry);
      console.info(JSON.stringify({ event: "llm_call", ...entry, usd: Number(entry.usd.toFixed(6)) }));
    }
  }
  yield { type: "done", entry: entry!, stopReason: final?.stop_reason ?? null };
}

What each part protects against:

  • One client, created once, with a finite timeout and maxRetries: 2. The SDK retries 429 and 5xx responses (including 529 overloaded) with backoff by itself; you do not write retry loops.
  • env() is lazy. next build imports this module, and the build has no secrets by design (Tutorial 6). The settings are parsed on the first request instead.
  • { signal } on the stream. When the browser disconnects or the user presses Stop, the request to the model is cancelled as well, and generation stops being billed.
  • The finally block costs every call that produced usage, including stopped and failed streams. Tokens generated before a Stop are billed by the provider; a ledger that only records completed calls under-reports spend.
  • The log line has a fixed shape and contains no prompt text and no key. It is enough for dashboards and billing reconciliation, and safe to ship to a log service.

6. The streaming route, now with a real model#

The route keeps everything from Tutorial 5 and swaps fakeModel for the gateway. Delete lib/fake-model.ts.

app/api/generate/route.ts
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { authenticate } from "@/lib/auth";
import { apiError } from "@/lib/http";
import { BudgetExceededError, budgetFor, generateForUser } from "@/lib/llm";
import { rateLimit } from "@/lib/rate-limit";
import { SSE_HEADERS, SSE_HEARTBEAT, sseEvent } from "@/lib/sse";
 
const Body = z.object({ prompt: z.string().trim().min(1).max(4_000) });
 
const secondsUntilMidnightUTC = () => {
  const now = new Date();
  return Math.ceil((Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1) - now.getTime()) / 1000);
};
 
/** Turn a failure into a message that is safe to show a user. Most specific error class first. */
function describe(error: unknown): string {
  if (error instanceof BudgetExceededError) return "You have reached today's AI budget.";
  if (error instanceof Anthropic.RateLimitError) return "The AI service is busy. Please retry in a moment.";
  if (error instanceof Anthropic.InternalServerError) return "The AI service is temporarily unavailable (it may be overloaded). Please retry.";
  if (error instanceof Anthropic.APIConnectionError) return "Could not reach the AI service. Please retry.";
  return "Generation failed. Please retry.";
}
 
export async function POST(request: Request) {
  // 1. Who is calling? Every route that costs money starts here.
  const caller = await authenticate(request);
  if (!caller) return apiError(401, "Not signed in");
 
  // 2. Are they within their limits? Requests per minute, then dollars per day.
  const limit = rateLimit(`generate:${caller.userId}`, 10, 60);
  if (!limit.allowed) {
    return apiError(429, "Rate limit reached. Try again shortly.", { headers: { "Retry-After": String(limit.retryAfter) } });
  }
  if (budgetFor(caller.userId).remainingUSD <= 0) {
    return apiError(429, "Daily AI budget reached. It resets at midnight UTC.", { headers: { "Retry-After": String(secondsUntilMidnightUTC()) } });
  }
 
  // 3. Is the input valid?
  const parsed = Body.safeParse(await request.json().catch(() => null));
  if (!parsed.success) return apiError(400, "Invalid request", { issues: parsed.error.issues });
 
  // 4. Stream the reply as Server-Sent Events.
  const encoder = new TextEncoder();
  const userId = caller.userId;
 
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      const send = (chunk: string) => controller.enqueue(encoder.encode(chunk));
      // Models can pause for seconds before the first token; heartbeats keep proxies from closing the connection.
      const heartbeat = setInterval(() => send(SSE_HEARTBEAT), 1_000);
      let n = 0;
      try {
        send(sseEvent("start", {}, n++));
        for await (const event of generateForUser(userId, parsed.data.prompt, request.signal)) {
          if (event.type === "text") send(sseEvent("token", { text: event.text }, n++));
          else {
            const { requestId, model, inputTokens, outputTokens, usd } = event.entry;
            const { remainingUSD } = budgetFor(userId);
            send(sseEvent("done", { requestId, model, inputTokens, outputTokens, usd, remainingUSD, stopReason: event.stopReason }, n++));
          }
        }
      } catch (error) {
        if (!request.signal.aborted) {
          console.error("generation failed", { userId, status: error instanceof Anthropic.APIError ? error.status : undefined, error });
          send(sseEvent("error", { message: describe(error) }));
        }
      } finally {
        clearInterval(heartbeat);
        try {
          controller.close();
        } catch {
          // already closed because the client disconnected
        }
      }
    },
  });
 
  return new Response(stream, { headers: SSE_HEADERS });
}

The order of describe's checks matters: the SDK's error classes form a hierarchy (RateLimitError and InternalServerError are both APIErrors, and in the TypeScript SDK so is APIConnectionError), so the most specific check comes first. A 529 "overloaded" arrives as an InternalServerError, after the SDK's own retries have been used up.

And a route for the caller to see their spend:

app/api/usage/route.ts
import { authenticate } from "@/lib/auth";
import { apiError } from "@/lib/http";
import { recentFor } from "@/lib/ledger";
import { budgetFor } from "@/lib/llm";
 
const round = (usd: number) => Number(usd.toFixed(6));
 
export async function GET(request: Request) {
  const caller = await authenticate(request);
  if (!caller) return apiError(401, "Not signed in");
 
  const { limitUSD, spentUSD, remainingUSD } = budgetFor(caller.userId);
  return Response.json({
    date: new Date().toISOString().slice(0, 10),
    limitUSD,
    spentUSD: round(spentUSD),
    remainingUSD: round(remainingUSD),
    recent: recentFor(caller.userId).map(({ at, model, inputTokens, outputTokens, usd, outcome }) => ({ at, model, inputTokens, outputTokens, usd: round(usd), outcome })),
  });
}

7. Update the client script#

The end of scripts/client.ts now reads the cost from the done event and asks for today's usage:

scripts/client.ts (from step 3 of the script)
// 3. From now on, act like another program: authenticate with the key only.
const response = await fetch(`${BASE}/api/generate`, {
  method: "POST",
  headers: { ...json, Authorization: `Bearer ${created.key}` },
  body: JSON.stringify({ prompt: "Explain Server-Sent Events in one sentence." }),
});
 
if (!response.ok) throw new Error(`generate failed: ${response.status} ${await response.text()}`);
for await (const message of readSSE(response)) {
  const data = JSON.parse(message.data) as { text?: string; message?: string; model?: string; inputTokens?: number; outputTokens?: number; usd?: number; remainingUSD?: number };
  if (message.event === "token") process.stdout.write(data.text ?? "");
  else if (message.event === "done") {
    console.log(`\ndone: ${data.model}, ${data.inputTokens} in / ${data.outputTokens} out, $${data.usd?.toFixed(6)}, $${data.remainingUSD?.toFixed(4)} left today`);
  } else if (message.event === "error") throw new Error(data.message);
}
 
// 4. What has this user spent today?
const usage = (await (await fetch(`${BASE}/api/usage`, { headers: { cookie } })).json()) as { spentUSD: number; limitUSD: number; recent: unknown[] };
console.log(`usage today: $${usage.spentUSD} of $${usage.limitUSD} across ${usage.recent.length} call(s)`);

8. Tests that cost nothing#

The pricing and ledger tests are plain arithmetic:

test/pricing.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { recentFor, record, spentTodayUSD } from "../lib/ledger.ts";
import { costUSD } from "../lib/pricing.ts";
 
const usage = (input: number, output: number, cacheWrite = 0, cacheRead = 0) => ({
  input_tokens: input,
  output_tokens: output,
  cache_creation_input_tokens: cacheWrite,
  cache_read_input_tokens: cacheRead,
});
const close = (actual: number, expected: number) => assert.ok(Math.abs(actual - expected) < 1e-12, `${actual} != ${expected}`);
 
test("input and output tokens use the model's prices", () => {
  close(costUSD("claude-opus-5-5", usage(1_000_000, 0)), 4);
  close(costUSD("claude-opus-5-5", usage(0, 1_000_000)), 20);
  close(costUSD("claude-sonnet-5-5", usage(2_000, 500)), (2_000 * 2 + 500 * 10) / 1e6);
});
 
test("cache writes cost 1.25x input and cache reads cost the cache-read price", () => {
  close(costUSD("claude-opus-5-5", usage(0, 0, 1_000_000)), 5);
  close(costUSD("claude-opus-5-5", usage(0, 0, 0, 1_000_000)), 0.2);
});
 
test("Haiku switches rate card above 100K prompt tokens, counting cached tokens", () => {
  close(costUSD("claude-haiku-5-5", usage(100_000, 1_000)), (100_000 * 0.1 + 1_000 * 0.5) / 1e6);
  close(costUSD("claude-haiku-5-5", usage(100_001, 1_000)), (100_001 * 0.5 + 1_000 * 2.5) / 1e6);
  close(costUSD("claude-haiku-5-5", usage(10_000, 0, 0, 95_000)), (10_000 * 0.5 + 95_000 * 0.05) / 1e6);
});
 
test("the ledger adds spend per user per UTC day", () => {
  const at = new Date("2026-10-18T23:59:00Z");
  const entry = { userId: "u_ledger", requestId: "r", model: "claude-opus-5-5", inputTokens: 1, outputTokens: 1, outcome: "completed" } as const;
  record({ ...entry, at, usd: 0.25 });
  record({ ...entry, at, usd: 0.125 });
  record({ ...entry, at: new Date("2026-10-19T00:01:00Z"), usd: 1 });
  assert.equal(spentTodayUSD("u_ledger", at), 0.375);
  assert.equal(spentTodayUSD("u_ledger", new Date("2026-10-19T12:00:00Z")), 1);
  assert.equal(spentTodayUSD("someone-else", at), 0);
  assert.equal(recentFor("u_ledger")[0]?.usd, 1); // newest first
});

The gateway tests start the mock on a free port (0) and point the SDK at it before importing lib/llm.ts, because the client reads its environment when it is created:

test/llm.test.ts
import assert from "node:assert/strict";
import { after, test } from "node:test";
import Anthropic from "@anthropic-ai/sdk";
import { startMockClaude } from "../scripts/mock-claude.ts";
 
// Point the SDK at a local mock before lib/llm.ts creates its client: these tests cost nothing.
const mock = await startMockClaude(0);
const API_KEY = "sk-ant-test-key-that-must-never-be-logged";
Object.assign(process.env, {
  ANTHROPIC_BASE_URL: mock.url,
  ANTHROPIC_API_KEY: API_KEY,
  SESSION_SECRET: "x".repeat(32),
  DAILY_BUDGET_USD: "0.002",
});
const { BudgetExceededError, budgetFor, generateForUser } = await import("../lib/llm.ts");
const { recentFor } = await import("../lib/ledger.ts");
after(() => mock.close());
 
async function run(userId: string, prompt = "Say hello") {
  let text = "";
  let usd = 0;
  for await (const event of generateForUser(userId, prompt, new AbortController().signal)) {
    if (event.type === "text") text += event.text;
    else usd = event.entry.usd;
  }
  return { text, usd };
}
 
test("streams the reply, then costs and records the call", async () => {
  const { text, usd } = await run("u_stream");
  assert.match(text, /Mock reply to "Say hello"/);
  assert.ok(usd > 0);
  assert.equal(budgetFor("u_stream").spentUSD, usd);
  assert.equal(recentFor("u_stream")[0]?.outcome, "completed");
});
 
test("an overloaded API (529) is retried by the SDK without the caller noticing", async () => {
  mock.failNext(2, 529);
  const { text } = await run("u_retry");
  assert.match(text, /Mock reply/);
});
 
test("a rate limit that outlasts the retries surfaces as a typed error, and nothing is billed", async () => {
  mock.failNext(3, 429); // the first attempt plus maxRetries: 2
  await assert.rejects(run("u_limited"), Anthropic.RateLimitError);
  assert.equal(budgetFor("u_limited").spentUSD, 0);
  mock.failNext(0);
});
 
test("a stream the user stops is still recorded, because those tokens are billed", async () => {
  const controller = new AbortController();
  await assert.rejects(async () => {
    for await (const event of generateForUser("u_stop", "Say hello", controller.signal)) {
      if (event.type === "text") controller.abort(); // the user presses Stop after the first words
    }
  });
  const [entry] = recentFor("u_stop");
  assert.equal(entry?.outcome, "stopped");
  assert.ok((entry?.inputTokens ?? 0) > 0);
});
 
test("the daily budget stops a user once it is spent", async () => {
  await assert.rejects(async () => {
    for (let i = 0; i < 10; i++) await run("u_budget");
  }, BudgetExceededError);
  assert.equal(budgetFor("u_budget").remainingUSD, 0);
});
 
test("the API key never appears in logs", async () => {
  const lines: string[] = [];
  const original = console.info;
  console.info = (...args: unknown[]) => void lines.push(args.join(" "));
  try {
    await run("u_logs");
  } finally {
    console.info = original;
  }
  assert.ok(lines.some((l) => l.includes('"event":"llm_call"')));
  assert.ok(lines.every((l) => !l.includes(API_KEY)));
});

Update the defaults test in test/limits-and-env.test.ts for the new variables:

test/limits-and-env.test.ts (replace the last test)
test("parseEnv applies defaults", () => {
  const env = parseEnv({ SESSION_SECRET: "x".repeat(32), ANTHROPIC_API_KEY: "sk-ant-test" });
  assert.deepEqual(env, {
    NODE_ENV: "development",
    SESSION_SECRET: "x".repeat(32),
    GIT_SHA: "dev",
    ANTHROPIC_API_KEY: "sk-ant-test",
    CLAUDE_MODEL: "claude-opus-5-5",
    DAILY_BUDGET_USD: 0.5,
  });
});
 
test("parseEnv rejects a model without a price and a key in the wrong format", () => {
  assert.throws(() => parseEnv({ SESSION_SECRET: "x".repeat(32), ANTHROPIC_API_KEY: "sk-ant-test", CLAUDE_MODEL: "gpt-5" }), /CLAUDE_MODEL/);
  assert.throws(() => parseEnv({ SESSION_SECRET: "x".repeat(32), ANTHROPIC_API_KEY: "my-key" }), /ANTHROPIC_API_KEY/);
});
npm test            # 25 passing, in a few seconds, with no API key and no network
npm run typecheck

The retry tests take a second or two: that is the SDK's real backoff between attempts.

9. Run it#

Start the mock in one terminal and the app in another:

node scripts/mock-claude.ts
npm run dev

Then run the client:

node scripts/client.ts
output against the mock
signed in, session cookie received
created API key key_25cb76e11872
Mock reply to "Explain Server-Sent Events in one sentence.". Unset ANTHROPIC_BASE_URL to talk to the real API.
done: claude-opus-5-5, 31 in / 34 out, $0.000804, $0.4992 left today
usage today: $0.000804 of $0.5 across 1 call(s)

The server's terminal shows the structured log line for the call:

{"event":"llm_call","at":"2026-10-18T19:54:25.828Z","userId":"user_ada","requestId":"71df5af2-b523-4aa2-b37a-dab36630e368","model":"claude-opus-5-5","inputTokens":31,"outputTokens":34,"usd":0.000804,"outcome":"completed"}

Watch the budget work. Restart the app with a tiny budget, DAILY_BUDGET_USD=0.003 npm run dev, run the client once, then sign in with curl as in Tutorial 5 and call /api/generate six times:

200 200 200 200 429 429
HTTP/1.1 429 Too Many Requests
retry-after: 15116
{"error":"Daily AI budget reached. It resets at midnight UTC."}

/api/usage then reports "spentUSD": 0.003252 against a "limitUSD": 0.003. The budget is checked before each call, so the last call allowed can overshoot by up to one call's cost. max_tokens is what bounds that overshoot; if overshoot matters, reserve the worst-case cost of a call before making it.

Switch to the real API. Put your real key in .env.local, remove the ANTHROPIC_BASE_URL line, restart, and run the client again. The reply is now Claude's, and the cost in the done event comes from real usage. After a few calls, and once the Claude Console's usage page has caught up, compare it with the ledger's total; they should agree closely. A large gap means a call path that bypasses the gateway, or a price in lib/pricing.ts that needs updating.

10. Keep CI free#

The smoke test from Tutorial 6 starts the container and runs the client. The container now refuses to start without ANTHROPIC_API_KEY, and CI should never spend money or hold your real key. Run the mock on the runner and point the container at it:

.github/workflows/ci.yml (image job, replacing the start step)
      - name: Start the mock Claude API
        run: node scripts/mock-claude.ts &
      - name: Start the image against the mock, with throwaway secrets
        run: |
          docker run -d --name api --network host \
            -e SESSION_SECRET="$(openssl rand -base64 32)" \
            -e ANTHROPIC_API_KEY=sk-ant-ci-mock \
            -e ANTHROPIC_BASE_URL=http://localhost:4010 \
            secure-api:${{ github.sha }}
          for i in $(seq 1 30); do curl -fs http://localhost:3000/api/health && exit 0; sleep 1; done
          exit 1

--network host lets the container reach the mock on the runner's localhost. The mock imports only Node.js built-ins, so this job still needs no npm ci. The pipeline now tests the real image, the real SDK, and the real streaming path on every pull request, for free.

Common mistakes#

  • Calling the SDK from more than one place. The second call site will forget the budget or the cost logging. Keep one gateway and make every route use it.
  • Recording spend only for completed calls. Stopped and failed streams are billed for the tokens they produced. Record them too.
  • Writing your own retry loop around the SDK. It already retries 429, 5xx, and connection errors with backoff. Nested retries multiply cost and latency.
  • Matching error messages as strings. Use the SDK's error classes, most specific first.
  • Sums of dollars in floating point. Keep counters in integer micro-dollars.
  • Tests that call the real API. They are slow, flaky, cost money, and cannot run on pull requests from forks. Mock the wire format; measure quality separately with evals.

Try it

Extend the gateway

  1. Add a CLAUDE_MODEL=claude-haiku-5-5 run, call the API a few times, and compare usd per call in /api/usage with Opus.
  2. Reserve before spending: compute the worst-case cost of a call (max_tokens at the output price plus the counted input) and refuse when it exceeds the remaining budget, so the budget can never overshoot.
  3. Move the ledger's daily counters to Redis with the recordSpend helper from the billing article, and confirm two app instances share one budget.
  4. Add a /api/usage?days=7 view that groups spend by day and model.

Check your understanding

Why does generateForUser record spend in a finally block?
The mock returns 529 twice, then succeeds. What does the caller of generateForUser see?
Why do the tests and CI use a mock Claude API?

Key takeaways

  • Route every model call through one gateway that holds the key and enforces budgets, costs, and logging.
  • Let the SDK handle retries and timeouts; map its typed errors to user-safe messages, most specific first.
  • Cost every call from its usage with one price table, and record stopped and failed calls too.
  • Check budgets before calling, and know that a pre-call check can overshoot by one call unless you reserve.
  • Mock the API's wire format so tests and CI stay free, and measure answer quality separately.

Next steps#

Finished this article? Track it to see your progress on the roadmap.