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.
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:
| Metric | Example | Where it comes from |
|---|---|---|
| Cost per action | $0.018 per support answer | usage × prices, recorded on every span |
| Actions per active user per month | 40 answers | Product analytics |
| Revenue (or value) per active user per month | $12 on the Pro plan | Pricing |
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
Show text descriptionHide 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.
| Step | Uncached input | Cache | Output | Total |
|---|---|---|---|---|
| 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).
| Lever | Typical saving | Quality risk | See |
|---|---|---|---|
| Prompt caching | 50–90% of input cost | None | Prompt caching |
| Model routing | 30–70% | Medium: needs evals | Below |
| Batches API | 50% of eligible work | None (latency only) | Batches |
| Shorter outputs, lower effort | 20–50% of output cost | Low to medium | Format instructions, effort, max_tokens |
| Less context | Varies | Medium | Better 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:
// 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;
}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:
Hover or tap a component to see what it does.
Show text descriptionHide 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:
- 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.
- A classifier. A small model labels each request's difficulty, and the label picks the model.
- A cascade. Try a mid-size model first; escalate to the capable model when the answer signals low confidence or fails validation.
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" };
}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_usdattribute 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.
// 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;
}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
- Describe one feature you are building as workloads in
plan.ts: requests per month, input and output tokens, and the stable prefix. - Compute the baseline and the effect of each lever. Which lever is worth the most for your workload?
- 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
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.
Related articles
- Prompt Caching and Batches: Cutting LLM API Costs in TypeScriptCut LLM costs with prompt caching and the Batches API: how prefix caching works, where to put breakpoints, how to verify cache hits, and how to model costs.
- Choosing LLMs and Providers: Capability vs Cost vs LatencyHow to pick an LLM per feature by capability, cost, and latency: the current Claude lineup, effort levels, routing, and a harness to benchmark your own tasks.
- LLM Observability: Tracing, Metrics, and Logs with OpenTelemetryInstrument an LLM app with OpenTelemetry in Next.js: GenAI spans with tokens, cost, cache hits, and time to first token, plus dashboards, alerts, and safe logging.