← Blog

Validate your LLM request body with Zod: the cheapest cost control you are not counting

A Zod schema on your chat route is a cost control. Cap message size and history so one request cannot balloon the bill, and add the total cap people miss.

Most AI apps meter and cap by request: 25 a month on free, 1,000 on Pro. That is the right unit to show users, but it quietly assumes every request costs about the same. It does not. The client decides how much history to send, and every token of that history is input you pay for. A request quota bounds how often someone can call the model; only the request body bounds how much each call costs. The schema on your chat route is a cost control, whether or not you wrote it as one.

The schema is the first line of spend control

The kit's chat route validates the body with Zod before any model work happens, right after the rate limiter:

const bodySchema = z.object({
  messages: z
    .array(
      z.object({
        role: z.enum(["user", "assistant"]),
        content: z.string().min(1).max(32_000),
      }),
    )
    .min(1)
    .max(100),
  model: z.string().optional(),
});

const parsed = bodySchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
  return Response.json({ error: "Invalid request body." }, { status: 400 });
}

Each line is doing security or cost work. role is an enum of user and assistant, so a client cannot smuggle in its own system turn. Each message has a length ceiling and the history has a depth ceiling. model is accepted as a plain string and then clamped to what the caller's plan allows, so asking for an expensive model gets you the default instead. And a body that is not even valid JSON falls into the same 400 through the .catch(() => null), without throwing.

Do the worst-case math on your own caps

Here is the arithmetic for exactly those caps. One hundred messages of 32,000 characters is 3.2 million characters in a single request. A common rule of thumb is about four characters per token for English text — the real ratio varies by model and language, so measure with the token-counting endpoint for anything precise — which puts that request around 800,000 tokens.

That fits inside Opus 4.8's 1M-token window. At Opus 4.8's list price of $5 per million input tokens, that is roughly $4 of input, plus up to $1.60 more if the model writes a full 64,000-token answer at $25 per million. So per-message and per-history caps bound a request, but loosely: a caller who maxes both can spend dollars per call. Multiply by a monthly request quota and the quota stops meaning what the pricing page implies. (On Haiku 4.5, with its 200K-token window, the API rejects a request that size outright — the routing trap is covered in the 1M-context cost tradeoff.)

Add the cap people miss: total size

Per-field limits do not compose into a sensible total. The fix is one more rule over the whole array — a ceiling on the combined length, sized for the conversations your product actually has:

const Messages = z
  .array(
    z.object({
      role: z.enum(["user", "assistant"]),
      content: z.string().min(1).max(32_000),
    }),
  )
  .min(1)
  .max(100)
  .refine(
    (msgs) => msgs.reduce((n, m) => n + m.content.length, 0) <= 120_000,
    "Conversation is too long.",
  );

With a 120,000-character total, the worst case falls from about 800K tokens to about 30K by the same rule of thumb — a more than twenty-fold tighter bound for one line of code. Pick the number from real usage: look at how long your longest legitimate conversations get, and set the ceiling comfortably above that rather than at whatever the model can technically accept.

Things that bite

  • Validate before you spend, not after. The order in the route matters: rate limit, then schema, then quota, then the model call. A request that fails validation costs nothing; one that fails after streaming has started has already billed.
  • Characters are not tokens. A character cap is cheap and good enough as a backstop. When a bound has to be exact — a plan that promises a context size, say — count tokens with the API before sending and reject over the limit.
  • Trim history server-side for long chats. Rejecting a long conversation with a 400 is a bad experience for a legitimate power user. For chat, keep the most recent turns that fit the budget and drop the oldest, instead of failing the request.
  • Cap the output too. Input is half the bill. A per-plan max_tokens ceiling bounds the other half, and it is a single field.
  • Quota counts and request size are one design. A request-count quota is only as tight as the largest request it allows; set them together, and meter the cost of each request so you can see when a heavy user is spending dollars per call.

Shipwright — the Next.js and Claude starter kit this blog documents — validates every chat request with a schema like this one before the rate-limited, quota-checked, plan-clamped model call. Try a request in the live demo.

Shipwright is a Next.js 16 + Claude starter kit that ships these patterns already done.

Try the live demo →