Skip to content
AI Full-Stack Wiki

Cost Engineering for LLM Apps: Unit Economics, Routing, and Budgets

Model and cut LLM costs in TypeScript: unit economics, a cost model for levers like caching and batching, model routing with escalation, and per-feature budgets.

Difficulty: Advanced19 min readUpdated
On this page· Start with unit economics

Why this matters

In most software, the marginal cost of a request is close to zero. In an LLM app, every request has a visible price, and it scales with usage, context size, and output length. A feature that costs $0.02 per use is fine at 1,000 uses a day and a crisis at a million. Cost engineering means knowing what each feature costs per user and per action, choosing the cheapest configuration that meets your quality bar, and enforcing limits before the invoice does.

Start with unit economics#

Before optimizing, connect cost to value. Three numbers per feature:

MetricExampleWhere it comes from
Cost per action$0.018 per support answerusage × prices, recorded on every span
Actions per active user per month40 answersProduct analytics
Revenue (or value) per active user per month$12 on the Pro planPricing

Here, AI costs $0.72 per user per month against $12 of revenue, which is healthy. The same feature on a free plan, with no limits, could lose money on every heavy user. Unit economics tell you where optimization matters and which limits each plan needs.

Know your levers#

  • Uncached input
  • Cache reads + writes
  • Output
Monthly cost of one product as cost levers are applied in turn: prompt caching, model routing, batching, and shorter outputs take the bill from $10,300 to about $2,970.
Show text description

A support product with 200,000 chat requests (9,000 input tokens, 500 output) and 50,000 background summaries (4,000 input, 300 output) per month, at list prices at the time of writing.

Monthly cost in US dollars
StepUncached inputCacheOutputTotal
Baseline$8,000$0$2,300$10,300
+ prompt caching$2,400$952$2,300$5,652
+ model routing$1,320$742$1,475$3,537
+ batching$1,220$742$1,438$3,400
+ shorter outputs$1,220$742$1,006$2,968

The chart shows one product's monthly bill as each lever is applied. The order matters: caching first (no quality change, large savings when prompts share a long prefix), then routing (needs evals), then batching (only for work that can wait), then output length (often a product decision as much as an engineering one).

LeverTypical savingQuality riskSee
Prompt caching50–90% of input costNonePrompt caching
Model routing30–70%Medium: needs evalsBelow
Batches API50% of eligible workNone (latency only)Batches
Shorter outputs, lower effort20–50% of output costLow to mediumFormat instructions, effort, max_tokens
Less contextVariesMediumBetter retrieval, history trimming

A cost model in code#

A small model of your workloads answers "what will this cost?" and "what is this lever worth?" before you build anything. It is the code that produced the chart above:

lib/cost-model.ts
// USD per million tokens at the time of writing. Keep this table in one place.
export const PRICES = {
  "claude-opus-5-5": { input: 4, cacheWrite: 5, cacheRead: 0.2, output: 20 },
  "claude-sonnet-5-5": { input: 2, cacheWrite: 2.5, cacheRead: 0.2, output: 10 },
  "claude-haiku-4-5": { input: 1, cacheWrite: 1.25, cacheRead: 0.1, output: 5 },
} as const;
export type Model = keyof typeof PRICES;
 
export interface Workload {
  name: string;
  requestsPerMonth: number;
  inputTokens: number; // per request, total
  cacheablePrefix: number; // stable prefix tokens (tools + system + shared docs)
  outputTokens: number;
}
 
export interface Levers {
  caching: boolean;
  cacheHitRate: number; // share of requests that read the prefix from cache
  routing: Partial<Record<string, { share: number; model: Model }>>; // share of a workload sent to a cheaper model
  batched: string[]; // workloads sent through the Batches API (50% off)
  outputReduction: number; // 0.3 means 30% fewer output tokens
}
 
export interface Breakdown {
  input: number;
  cache: number;
  output: number;
  total: number;
}
 
function costOf(w: Workload, model: Model, requests: number, l: Levers): Breakdown {
  const p = PRICES[model];
  const out = w.outputTokens * (1 - l.outputReduction);
  const discount = l.batched.includes(w.name) ? 0.5 : 1;
  let input: number;
  let cache = 0;
  if (l.caching && w.cacheablePrefix > 0) {
    const reads = requests * l.cacheHitRate;
    const writes = requests - reads;
    input = requests * (w.inputTokens - w.cacheablePrefix) * p.input;
    cache = reads * w.cacheablePrefix * p.cacheRead + writes * w.cacheablePrefix * p.cacheWrite;
  } else {
    input = requests * w.inputTokens * p.input;
  }
  const output = requests * out * p.output;
  const scale = discount / 1_000_000;
  return { input: input * scale, cache: cache * scale, output: output * scale, total: (input + cache + output) * scale };
}
 
export function monthlyCost(workloads: Workload[], baseModel: Model, l: Levers): Breakdown {
  const sum: Breakdown = { input: 0, cache: 0, output: 0, total: 0 };
  for (const w of workloads) {
    const route = l.routing[w.name];
    const parts: [Model, number][] = route
      ? [[route.model, w.requestsPerMonth * route.share], [baseModel, w.requestsPerMonth * (1 - route.share)]]
      : [[baseModel, w.requestsPerMonth]];
    for (const [model, requests] of parts) {
      const c = costOf(w, model, requests, l);
      sum.input += c.input;
      sum.cache += c.cache;
      sum.output += c.output;
      sum.total += c.total;
    }
  }
  return sum;
}
plan.ts
import { monthlyCost, type Levers, type Workload } from "./lib/cost-model.ts";
 
const workloads: Workload[] = [
  { name: "chat", requestsPerMonth: 200_000, inputTokens: 9_000, cacheablePrefix: 7_000, outputTokens: 500 },
  { name: "summaries", requestsPerMonth: 50_000, inputTokens: 4_000, cacheablePrefix: 0, outputTokens: 300 },
];
 
const baseline: Levers = { caching: false, cacheHitRate: 0, routing: {}, batched: [], outputReduction: 0 };
const caching: Levers = { ...baseline, caching: true, cacheHitRate: 0.9 };
const routing: Levers = {
  ...caching,
  routing: { chat: { share: 0.6, model: "claude-sonnet-5-5" }, summaries: { share: 1, model: "claude-haiku-4-5" } },
};
const batching: Levers = { ...routing, batched: ["summaries"] };
const shorter: Levers = { ...batching, outputReduction: 0.3 };
 
const steps: [string, Levers][] = [
  ["Baseline", baseline],
  ["+ prompt caching", caching],
  ["+ model routing", routing],
  ["+ batching", batching],
  ["+ shorter outputs", shorter],
];
for (const [label, levers] of steps) {
  const c = monthlyCost(workloads, "claude-opus-5-5", levers);
  console.log(label.padEnd(18), `$${c.total.toFixed(0).padStart(6)}`, `(input $${c.input.toFixed(0)}, cache $${c.cache.toFixed(0)}, output $${c.output.toFixed(0)})`);
}
Baseline           $ 10300 (input $8000, cache $0, output $2300)
+ prompt caching   $  5652 (input $2400, cache $952, output $2300)
+ model routing    $  3537 (input $1320, cache $742, output $1475)
+ batching         $  3400 (input $1220, cache $742, output $1438)
+ shorter outputs  $  2968 (input $1220, cache $742, output $1006)

The cache hit rate of 90% is a deliberately conservative assumption (every tenth request pays for a cache write). Replace the workloads with numbers from your traces and the model becomes a planning tool: run it before every pricing decision.

Model routing#

Not every request needs your most capable model. Routing sends each request to the cheapest model that handles it well:

Routing requests to the right modelA cheap router classifies each request; simple requests go to a small fast model, complex ones to the most capable model, and all answers are sampled into evals that check the routing stays good enough.RequestRouterrules or small modelSmall modelfast, cheapCapable modelhigher effortEvalssampled traffic

Hover or tap a component to see what it does.

Model routing: a router sends simple requests to a small, fast model and complex ones to a more capable model, with evals on sampled traffic to keep quality in check.
Show text description

Routing lets you pay for capability only where it is needed. It only works if you measure: without evals, cheap routes silently degrade.

  • Request: An incoming task, such as a support question or a code review.
  • Router: Decides how hard the task is. Often plain rules (input length, feature, customer tier) before reaching for a model.
  • Small model: Handles high-volume, well-defined tasks: classification, extraction, short answers.
  • Capable model: Handles open-ended reasoning, multi-step tool use, and anything where errors are costly.
  • Evals: Scores a sample of routed answers so you notice when the small model starts failing a category.

Three strategies, in the order to try them:

  1. Rules. The feature often decides: classification and extraction go to a small model; agentic coding goes to the most capable one. Free, instant, and predictable.
  2. A classifier. A small model labels each request's difficulty, and the label picks the model.
  3. A cascade. Try a mid-size model first; escalate to the capable model when the answer signals low confidence or fails validation.
lib/router.ts
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
 
const client = new Anthropic();
const text = (m: Anthropic.Message) => m.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
 
export type Feature = "tagging" | "support-chat" | "code-review";
 
// 1. Rules: most routing decisions are known per feature.
const BY_FEATURE: Record<Feature, Anthropic.Model | "classify"> = {
  tagging: "claude-haiku-4-5",
  "code-review": "claude-opus-5-5",
  "support-chat": "classify",
};
 
// 2. Classifier: a small, fast model labels difficulty.
const Difficulty = z.object({
  difficulty: z.enum(["simple", "complex"]).describe("complex = multi-step reasoning, troubleshooting, or account-specific judgment"),
});
 
export async function chooseModel(feature: Feature, input: string): Promise<Anthropic.Model> {
  const rule = BY_FEATURE[feature];
  if (rule !== "classify") return rule;
  const res = await client.messages.parse({
    model: "claude-haiku-4-5",
    max_tokens: 256,
    output_config: { format: zodOutputFormat(Difficulty) },
    messages: [{ role: "user", content: `Classify this support message:\n<message>\n${input}\n</message>` }],
  });
  return res.parsed_output?.difficulty === "simple" ? "claude-sonnet-5-5" : "claude-opus-5-5";
}
 
// 3. Cascade: answer with a mid-size model; escalate when it says it is unsure.
const Answer = z.object({
  answer: z.string(),
  confident: z.boolean().describe("false if the question needs information or reasoning you are unsure about"),
});
 
export async function answerWithCascade(system: string, question: string): Promise<{ answer: string; model: string }> {
  const first = await client.messages.parse({
    model: "claude-sonnet-5-5",
    max_tokens: 4096,
    output_config: { effort: "low", format: zodOutputFormat(Answer) },
    system,
    messages: [{ role: "user", content: question }],
  });
  if (first.parsed_output?.confident) return { answer: first.parsed_output.answer, model: "claude-sonnet-5-5" };
 
  const escalated = await client.messages.create({
    model: "claude-opus-5-5",
    max_tokens: 8192,
    system,
    messages: [{ role: "user", content: question }],
  });
  return { answer: text(escalated), model: "claude-opus-5-5" };
}
route-demo.ts
import { answerWithCascade, chooseModel } from "./lib/router.ts";
 
console.log(await chooseModel("tagging", "Label this ticket"));
console.log(await chooseModel("support-chat", "How do I download an invoice?"));
 
const result = await answerWithCascade(
  "You are the support assistant for Acme Billing.",
  "Our SSO login fails only for users in the EU tenant since yesterday's certificate rotation. What should we check?",
);
console.log(result.model, result.answer.slice(0, 120));

Routing is a quality decision disguised as a cost decision. Before you ship a route, run your evals on each model for that feature, and keep evaluating a sample of routed traffic afterwards. A router that saves 60% but answers 10% of hard questions badly is usually a bad trade.

Budgets and alerts#

Limits turn cost from a monthly surprise into an engineering constraint:

  • Per request: cap input size and max_tokens; cap agent turns and tokens per run.
  • Per user: daily budgets per plan (the billing article has a Redis implementation), with a clear message when reached.
  • Per feature: a daily budget and an alert on unusual growth, computed from the app.cost_usd attribute on your traces.
  • Per organization: spending limits in the provider console, and separate API keys (and workspaces) per environment and per team, so one runaway job cannot consume everything.
lib/feature-budget.ts
// Daily per-feature spend check, fed from your cost records (for example a nightly query over trace data).
export interface FeatureSpend {
  feature: string;
  todayUSD: number;
  trailing7DayAvgUSD: number;
  dailyBudgetUSD: number;
}
 
export type Alert = { feature: string; level: "warning" | "critical"; message: string };
 
export function checkBudgets(spend: FeatureSpend[]): Alert[] {
  const alerts: Alert[] = [];
  for (const s of spend) {
    if (s.todayUSD >= s.dailyBudgetUSD) {
      alerts.push({ feature: s.feature, level: "critical", message: `${s.feature} spent $${s.todayUSD.toFixed(2)} of a $${s.dailyBudgetUSD} budget` });
    } else if (s.trailing7DayAvgUSD > 0 && s.todayUSD > s.trailing7DayAvgUSD * 1.5) {
      const pct = Math.round((s.todayUSD / s.trailing7DayAvgUSD - 1) * 100);
      alerts.push({ feature: s.feature, level: "warning", message: `${s.feature} is ${pct}% above its 7-day average` });
    }
  }
  return alerts;
}
feature-budget.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkBudgets } from "./lib/feature-budget.ts";
 
test("flags budget overruns and unusual growth", () => {
  const alerts = checkBudgets([
    { feature: "support-chat", todayUSD: 130, trailing7DayAvgUSD: 110, dailyBudgetUSD: 200 }, // normal
    { feature: "summaries", todayUSD: 95, trailing7DayAvgUSD: 40, dailyBudgetUSD: 150 }, // growth
    { feature: "agent", todayUSD: 310, trailing7DayAvgUSD: 250, dailyBudgetUSD: 300 }, // over budget
  ]);
  assert.deepEqual(alerts.map((a) => [a.feature, a.level]), [["summaries", "warning"], ["agent", "critical"]]);
  assert.match(alerts[0]!.message, /138% above/);
});

Common mistakes#

  • Optimizing before measuring. Record cost per request and per feature first; the expensive part is rarely where you expect.
  • Routing without evals. Savings you cannot measure the quality of are a risk, not a win.
  • Ignoring output tokens. At five times the input price, verbose answers dominate many bills.
  • Free tiers without limits. One heavy user, or one script, can cost more than a month of revenue.
  • One API key for everything. Separate keys per environment and team make spending visible and limitable.

Try it

Plan your own feature's costs

  1. Describe one feature you are building as workloads in plan.ts: requests per month, input and output tokens, and the stable prefix.
  2. Compute the baseline and the effect of each lever. Which lever is worth the most for your workload?
  3. Compute cost per active user per month and compare it with the revenue or budget per user. Decide the per-user daily limit for a free plan.

Check your understanding

Why apply prompt caching before model routing?
What is a cascade?
A free-plan feature costs $0.02 per use. What protects you from heavy users?

Key takeaways

  • Measure cost per action and per active user, and compare it with value per user.
  • Model your workloads in code to price levers before building them: caching, routing, batching, and shorter outputs.
  • Route with rules first, then a classifier or cascade, and validate every route with evals.
  • Enforce limits per request, user, feature, and organization, and alert on unusual growth.

Next steps#

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