When building revenue-generating services on Claude API, one truth surfaces quickly: running every request through the same model is leaving money on the table.
Claude Haiku handles classification and short summaries at a fraction of Sonnet's cost. Opus delivers genuine value for complex reasoning, but routing routine responses through it is like chartering a private jet for a grocery run. Dynamic model routing — selecting the right model for each task — is one of the highest-leverage optimizations available to Claude API developers.
In this guide, I'll walk through the complete implementation: routing logic, quality fallback, prompt caching integration, production monitoring, and real-world margin data from services I've run.
The Cost Reality That Makes Routing Worth It
As of May 2026, Anthropic's pricing per million tokens:
- Claude Haiku 4.5: $0.80 input / $4.00 output
- Claude Sonnet 4.6: $3.00 input / $15.00 output
- Claude Opus 4.6: $15.00 input / $75.00 output
The output token gap is where routing pays off most. Haiku output is 3.75x cheaper than Sonnet's and 18.75x cheaper than Opus's.
On a typical AI assistant request (500 input tokens, 300 output tokens):
- Haiku: $0.000040 + $0.000120 = $0.000160 per request
- Sonnet: $0.000150 + $0.000450 = $0.000600 per request
- Opus: $0.000750 + $0.002250 = $0.003000 per request
At one million monthly requests on Sonnet alone: $600/month.
Routing 70% to Haiku: 700k × $0.000160 + 300k × $0.000600 = $112 + $180 = $292/month — a 51% reduction with zero change to user-facing quality for appropriately routed tasks.
The math becomes more dramatic at scale. At 10 million requests/month, the difference between Sonnet-only ($6,000) and a well-tuned routing split ($2,900) is $3,100/month — more than most solo developer salaries.
Task Classification Framework: What Goes Where
Your routing table design determines everything. Here's the framework I use in production, built from months of testing across different service types.
Route to Haiku — fast, cheap, sufficient:
- Sentiment analysis, spam detection, category classification
- Short summaries (under 300 words)
- Structured data extraction (unstructured text → JSON)
- FAQ and template-based responses where answer quality is mostly determined by the template
- General-purpose translation (non-technical, non-legal)
- Input validation and content moderation
- Simple question answering from a provided document
Route to Sonnet — balanced capability:
- Medium-length content generation (emails, blog post drafts, product descriptions)
- Code explanation, bug identification, simple refactoring suggestions
- Multi-document cross-analysis where you need synthesis, not deep reasoning
- Conversational AI responses where naturalness matters more than depth
- Technical translation (documentation, code comments)
Route to Opus — reserve for what actually needs it:
- Legal, medical, or financial reasoning where accuracy is liability-critical
- Complex multi-step reasoning problems (math proofs, strategic analysis)
- High-stakes creative writing where quality directly affects conversion or revenue
- Critical code architecture review where a missed issue costs real money
- Anything where you've discovered from quality fallback data that Sonnet isn't good enough
The key insight here: most services have far more Haiku-appropriate tasks than they realize. When I audited one of my services, 68% of requests were classification, summarization, or FAQ-type tasks. The product felt like a "smart AI assistant" — but most of the intelligence came from carefully crafted prompts, not model capability.
Implementation: The Routing Engine
I use a hybrid approach — rule-based routing for known task types with Haiku-as-classifier for ambiguous inputs. The classification call itself costs essentially nothing.
// lib/model-router.ts
import Anthropic from "@anthropic-ai/sdk";
export type TaskType =
| "classification"
| "short_summary"
| "data_extraction"
| "faq"
| "translation"
| "long_content"
| "code_review"
| "analysis"
| "expert_judgment"
| "complex_reasoning";
export type ClaudeModel =
| "claude-haiku-4-5-20251001"
| "claude-sonnet-4-6"
| "claude-opus-4-6";
// Single source of truth — update here when Anthropic releases new models
const ROUTING_TABLE: Record<TaskType, ClaudeModel> = {
classification: "claude-haiku-4-5-20251001",
short_summary: "claude-haiku-4-5-20251001",
data_extraction: "claude-haiku-4-5-20251001",
faq: "claude-haiku-4-5-20251001",
translation: "claude-haiku-4-5-20251001",
long_content: "claude-sonnet-4-6",
code_review: "claude-sonnet-4-6",
analysis: "claude-sonnet-4-6",
expert_judgment: "claude-opus-4-6",
complex_reasoning:"claude-opus-4-6",
};
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// When task type is unknown: let Haiku classify it
// Cost: ~$0.000001 per classification — negligible
async function classifyWithHaiku(prompt: string): Promise<TaskType> {
const response = await anthropic.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 50,
system: `You are a task classifier. Given a user prompt, classify it into exactly one of:
classification, short_summary, data_extraction, faq, translation,
long_content, code_review, analysis, expert_judgment, complex_reasoning.
Respond with ONLY the category name, nothing else.`,
messages: [
{
role: "user",
content: `Classify this task:\n\n${prompt.slice(0, 500)}`,
},
],
});
const raw = (
response.content[0] as { type: "text"; text: string }
).text
.trim()
.toLowerCase() as TaskType;
// Fall back to "analysis" (Sonnet) if unrecognized
return ROUTING_TABLE[raw] ? raw : "analysis";
}
export async function routeToModel(
prompt: string,
explicitTaskType?: TaskType
): Promise<ClaudeModel> {
const taskType = explicitTaskType ?? (await classifyWithHaiku(prompt));
return ROUTING_TABLE[taskType];
}The design principle: centralize routing in one table. When Anthropic releases Claude 5, you update two lines, not 50 API call sites.
Implementation: Adaptive Client with Quality Fallback
The routing engine feeds into an adaptive client that tracks costs and can escalate to Opus when quality falls short.
// lib/adaptive-claude-client.ts
import Anthropic from "@anthropic-ai/sdk";
import { routeToModel, TaskType, ClaudeModel } from "./model-router";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
interface CallOptions {
taskType?: TaskType;
maxTokens?: number;
systemPrompt?: string;
// If set, Haiku evaluates the response; if score < threshold, retry with Opus
qualityThreshold?: number;
userId?: string;
}
interface CallResult {
text: string;
model: ClaudeModel;
inputTokens: number;
outputTokens: number;
costUsd: number;
fallbackUsed: boolean;
qualityScore?: number;
}
// Per-million-token costs (update when pricing changes)
const COST_PER_MILLION: Record<ClaudeModel, { input: number; output: number }> = {
"claude-haiku-4-5-20251001": { input: 0.8, output: 4.0 },
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
"claude-opus-4-6": { input: 15.0, output: 75.0 },
};
function calculateCost(
model: ClaudeModel,
inputTokens: number,
outputTokens: number
): number {
const r = COST_PER_MILLION[model];
return (inputTokens / 1_000_000) * r.input + (outputTokens / 1_000_000) * r.output;
}
export async function callClaude(
prompt: string,
options: CallOptions = {}
): Promise<CallResult> {
const {
taskType,
maxTokens = 1024,
systemPrompt,
qualityThreshold,
userId,
} = options;
// Step 1: Route to the appropriate model
const model = await routeToModel(prompt, taskType);
// Step 2: Call Claude
const response = await anthropic.messages.create({
model,
max_tokens: maxTokens,
system: systemPrompt,
messages: [{ role: "user", content: prompt }],
});
const inputTokens = response.usage.input_tokens;
const outputTokens = response.usage.output_tokens;
const text = (response.content[0] as { type: "text"; text: string }).text;
const costUsd = calculateCost(model, inputTokens, outputTokens);
// Step 3: Optional quality check — escalate to Opus if score falls below threshold
// Only applies when we're not already on Opus
if (qualityThreshold !== undefined && model !== "claude-opus-4-6") {
const qualityScore = await evaluateQualityWithHaiku(text, prompt);
if (qualityScore < qualityThreshold) {
console.log(
`[Quality Fallback] Score ${qualityScore.toFixed(2)} < ${qualityThreshold}. ` +
`Escalating ${model} → claude-opus-4-6`
);
const fallback = await anthropic.messages.create({
model: "claude-opus-4-6",
max_tokens: maxTokens,
system: systemPrompt,
messages: [{ role: "user", content: prompt }],
});
const fbInput = fallback.usage.input_tokens;
const fbOutput = fallback.usage.output_tokens;
const fbText = (fallback.content[0] as { type: "text"; text: string }).text;
if (userId) {
void logUsage(userId, "claude-opus-4-6", fbInput, fbOutput,
calculateCost("claude-opus-4-6", fbInput, fbOutput), true);
}
return {
text: fbText,
model: "claude-opus-4-6",
inputTokens: fbInput,
outputTokens: fbOutput,
costUsd: calculateCost("claude-opus-4-6", fbInput, fbOutput),
fallbackUsed: true,
qualityScore,
};
}
if (userId) void logUsage(userId, model, inputTokens, outputTokens, costUsd, false);
return { text, model, inputTokens, outputTokens, costUsd, fallbackUsed: false, qualityScore };
}
if (userId) void logUsage(userId, model, inputTokens, outputTokens, costUsd, false);
return { text, model, inputTokens, outputTokens, costUsd, fallbackUsed: false };
}
// Lightweight quality evaluator — runs on Haiku, costs ~$0.000002 per eval
async function evaluateQualityWithHaiku(
response: string,
originalPrompt: string
): Promise<number> {
const evalResponse = await anthropic.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 10,
messages: [
{
role: "user",
content:
`Rate the quality of this AI response from 0.0 to 1.0.\n` +
`Question: "${originalPrompt.slice(0, 200)}"\n` +
`Response: "${response.slice(0, 500)}"\n` +
`Reply with ONLY a decimal number between 0.0 and 1.0.`,
},
],
});
const raw = (evalResponse.content[0] as { type: "text"; text: string }).text;
const score = parseFloat(raw);
return isNaN(score) ? 0.5 : Math.min(1.0, Math.max(0.0, score));
}
async function logUsage(
userId: string,
model: ClaudeModel,
inputTokens: number,
outputTokens: number,
costUsd: number,
fallbackUsed: boolean
): Promise<void> {
// Persist to DB, send to Stripe Metered Billing, or push to analytics
console.log(
`[Usage] user=${userId} model=${model} ` +
`in=${inputTokens} out=${outputTokens} ` +
`cost=$${costUsd.toFixed(6)} fallback=${fallbackUsed}`
);
}Compounding Savings with Prompt Caching
Model switching and prompt caching are independent optimizations that multiply together. Cache reads cost 1/10 of standard input token pricing.
Here's how to structure cacheable system prompts:
// lib/cached-prompts.ts
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// Build a system prompt that Anthropic will cache after first use
function buildCachedSystemPrompt(
baseInstructions: string,
contextDocuments: string[]
): Anthropic.TextBlockParam[] {
return [
{
type: "text",
text: baseInstructions,
cache_control: { type: "ephemeral" },
},
...contextDocuments.map((doc) => ({
type: "text" as const,
text: doc,
cache_control: { type: "ephemeral" as const },
})),
];
}
// Example: legal document assistant with large context
const LEGAL_SYSTEM = buildCachedSystemPrompt(
"You are a legal document analysis assistant. " +
"Answer questions based strictly on the provided documents.",
[longLegalDoc1, longLegalDoc2, longLegalDoc3] // ~15,000 tokens total
);
export async function analyzeLegalQuery(userQuestion: string): Promise<string> {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-6", // Sonnet for legal analysis (not Haiku)
max_tokens: 1024,
system: LEGAL_SYSTEM,
messages: [{ role: "user", content: userQuestion }],
});
// Check how many tokens were served from cache
const usage = response.usage as Anthropic.Usage & {
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
const cacheHit = usage.cache_read_input_tokens ?? 0;
const cacheMiss = usage.cache_creation_input_tokens ?? 0;
console.log(`Cache: ${cacheHit} hit / ${cacheMiss} created`);
return (response.content[0] as { type: "text"; text: string }).text;
}Cost projection for 15,000-token system prompt, 100k calls/month:
- Without caching: 15k × 100k × $3/M = $4,500/month
- With caching: ~$45 (first-call creation) + 15k × 100k × $0.30/M = $450/month (90% reduction)
Layer model switching on top — routing 65% of queries to Haiku instead of Sonnet — and the combined savings compound to 92–95% versus a naive Opus/no-cache baseline.
Production Monitoring: Keeping Routing Healthy
Model routing needs ongoing monitoring. Without it, routing drift silently erodes your savings or, worse, silently degrades quality.
// types/usage-log.ts
interface UsageLog {
requestId: string;
userId: string;
timestamp: Date;
taskType: string;
modelUsed: ClaudeModel;
routingReason: "rule_based" | "llm_classified" | "quality_fallback";
inputTokens: number;
outputTokens: number;
costUsd: number;
qualityScore?: number;
fallbackUsed: boolean;
latencyMs: number;
}
// Weekly aggregation targets
interface HealthTargets {
haikuShareMin: 0.55; // Alert if Haiku drops below 55%
haikuShareTarget: 0.65; // Healthy target
fallbackRateMax: 0.05; // Alert if fallback exceeds 5%
costPerRequestMax: 0.001; // Alert if avg cost exceeds $0.001
}Metrics to track weekly:
- Model distribution: What percentage of requests go to Haiku / Sonnet / Opus? If Haiku share drops unexpectedly, your routing table may be misclassifying.
- Fallback rate: If more than 5% of requests trigger quality fallback to Opus, your quality threshold is miscalibrated or your Haiku-routed tasks are actually Sonnet-level work.
- Cost per request trend: Week-over-week change in average cost. Rising cost without rising usage means routing drift.
- Latency by model: Haiku is significantly faster than Opus. If latency spikes, check whether routing is sending more traffic to slower models.
I run a weekly automated report that sends a Slack alert if any metric breaks threshold. Five minutes of setup saves hours of debugging.
Common Pitfalls and How to Avoid Them
Pitfall 1: Using Sonnet to classify tasks. Classification prompts are short (100 input tokens, 10 output tokens). Haiku handles them with equivalent accuracy at 1/4 the cost. Using Sonnet for classification effectively taxes every downstream routed request.
Pitfall 2: Assuming Opus always wins on quality. For FAQ responses, data extraction, and standard summaries, controlled A/B tests consistently show users can't distinguish Sonnet from Opus output. Reserve Opus for genuinely high-stakes reasoning — don't pattern-match "important feature" to "expensive model."
Pitfall 3: Miscalibrated quality thresholds. Setting qualityThreshold too high causes excessive Opus fallbacks that eliminate your savings. Start at 0.3 (meaning only clearly bad responses escalate), not 0.7. Tune upward based on actual support ticket data, not intuition.
Pitfall 4: Hardcoding model names in API calls. When Claude 4 Haiku releases with better capability at lower cost, you want to update your routing table in one place, not grep through 40 files. The centralized ROUTING_TABLE pattern is non-negotiable for maintainability.
Pitfall 5: Losing token counts in streaming responses. With streaming, the usage object is only available in the final message_stop event. Never attempt to report usage mid-stream. Capture token counts at stream completion, persist to DB, then report to Stripe asynchronously.
Pitfall 6: Not accounting for classification latency. Each Haiku classification call adds ~200ms of latency before the actual API call begins. For latency-sensitive applications, pass explicitTaskType from the calling layer whenever you know it — your UI layer often knows whether the user is submitting a form (data_extraction) versus composing a message (long_content).
Real-World Margin Impact
From a production AI assistant service I operate (anonymized at approximately 2M requests/month scale):
Before routing (Sonnet for everything):
- Monthly API cost: $1,200
- Support tickets related to AI quality: baseline
After routing (Haiku 68% / Sonnet 28% / Opus 4%):
- Monthly API cost: $440
- Support tickets related to AI quality: no measurable increase
- Savings: $760/month (63.3% reduction)
The 68% Haiku rate felt aggressive before launch. In practice, the service was classification-heavy: users submitted data for extraction, asked FAQ-type questions, and requested short summaries. Tasks that genuinely require Sonnet or Opus reasoning were the minority.
The honest caution: this result took two weeks of pre-production testing. I evaluated 100 samples per task type across all three models before setting the routing table. That investment is what makes 68% Haiku viable — not the routing framework itself.
Implementation Checklist
Before deploying model routing to production:
- [ ] Audit existing requests: what percentage are classification/summary vs. complex reasoning?
- [ ] Define task types that match your specific product (don't copy my table blindly)
- [ ] Evaluate 100 samples per task type across Haiku and Sonnet before setting the table
- [ ] Start with Haiku share at 50%, not 70% — tune upward with data
- [ ] Set quality thresholds conservatively (0.3, not 0.7)
- [ ] Add
routingReasonto every usage log for debugging - [ ] Build a weekly cost-per-request alert before launch, not after
- [ ] Test your streaming token capture logic before going live
Pricing Your Service: How Model Costs Translate to Customer Value
Understanding your API cost structure unlocks a second benefit beyond margin protection: rational pricing design. Most indie developers price AI services based on gut feel or competitor benchmarking. Knowing your actual cost floor lets you price with confidence.
Here's a framework I use to translate model costs into tier pricing:
// lib/pricing-calculator.ts
interface ServiceTier {
name: string;
monthlyPriceUsd: number;
monthlyCredits: number; // Internal billing unit
creditCostUsd: number; // Your API cost per credit
targetMarginPct: number; // e.g., 0.60 = 60% gross margin
}
// Given a tier definition, compute whether it's profitable at target margin
function analyzeTierProfitability(tier: ServiceTier): {
apiCostAtFullUsage: number;
grossMarginAtFullUsage: number;
breakEvenUsagePct: number;
recommendation: string;
} {
const apiCostAtFull = tier.monthlyCredits * tier.creditCostUsd;
const grossMarginAtFull = (tier.monthlyPriceUsd - apiCostAtFull) / tier.monthlyPriceUsd;
const breakEvenUsagePct = 1 - tier.targetMarginPct; // e.g., 40% usage = 60% margin
let recommendation: string;
if (grossMarginAtFull < 0) {
recommendation = "UNPROFITABLE at full usage — reduce credits or raise price";
} else if (grossMarginAtFull < tier.targetMarginPct) {
recommendation = "BELOW TARGET margin — consider 15-20% price increase";
} else {
recommendation = `HEALTHY — margin at full usage: ${(grossMarginAtFull * 100).toFixed(1)}%`;
}
return { apiCostAtFullUsage: apiCostAtFull, grossMarginAtFullUsage: grossMarginAtFull, breakEvenUsagePct, recommendation };
}
// Example: Sonnet-only vs. Routed cost per credit
const SONNET_COST_PER_CREDIT = 0.000600; // $0.000600 / request (500 in, 300 out)
const ROUTED_COST_PER_CREDIT = 0.000250; // Weighted average: 65% Haiku, 30% Sonnet, 5% Opus
const starterTier: ServiceTier = {
name: "Starter",
monthlyPriceUsd: 9.99,
monthlyCredits: 10_000,
creditCostUsd: ROUTED_COST_PER_CREDIT,
targetMarginPct: 0.65,
};
const result = analyzeTierProfitability(starterTier);
// apiCostAtFullUsage: $2.50 (10,000 credits × $0.000250)
// grossMarginAtFullUsage: 0.75 (75% — healthy)
// recommendation: "HEALTHY — margin at full usage: 75.0%"
// Without routing (Sonnet-only):
const starterTierNoRouting = { ...starterTier, creditCostUsd: SONNET_COST_PER_CREDIT };
const resultNoRouting = analyzeTierProfitability(starterTierNoRouting);
// apiCostAtFullUsage: $6.00 (10,000 × $0.000600)
// grossMarginAtFullUsage: -0.20 (-20% — UNPROFITABLE at full usage)Without routing, a $9.99/month tier with 10,000 credits becomes unprofitable if users actually use it. With routing, the same tier runs at 75% gross margin even at full utilization. This is why the pricing math only works if you've internalized your true per-request cost.
Multi-Tier Routing: Giving Power Users Access to Better Models
An advanced pattern I use in subscription-heavy services: expose model selection as a product feature. Free-tier users get Haiku-only routing; paid users get Sonnet access; premium users can explicitly request Opus for specific high-stakes tasks.
// lib/tier-aware-router.ts
type UserTier = "free" | "starter" | "pro" | "enterprise";
const TIER_MODEL_CEILING: Record<UserTier, ClaudeModel> = {
free: "claude-haiku-4-5-20251001",
starter: "claude-sonnet-4-6",
pro: "claude-sonnet-4-6",
enterprise: "claude-opus-4-6",
};
export async function routeForUser(
prompt: string,
userTier: UserTier,
taskType?: TaskType
): Promise<ClaudeModel> {
// Get the task-appropriate model
const taskModel = await routeToModel(prompt, taskType);
const tierCeiling = TIER_MODEL_CEILING[userTier];
// Downgrade if user's tier doesn't allow the task-recommended model
const modelHierarchy: ClaudeModel[] = [
"claude-haiku-4-5-20251001",
"claude-sonnet-4-6",
"claude-opus-4-6",
];
const taskIdx = modelHierarchy.indexOf(taskModel);
const ceilingIdx = modelHierarchy.indexOf(tierCeiling);
return modelHierarchy[Math.min(taskIdx, ceilingIdx)];
}This creates a natural upgrade path: free users occasionally get responses that would have been better on Sonnet, creating a genuine product reason to upgrade. The key is making the quality difference perceptible in your UX — show users when they're hitting their tier ceiling, and explain what they'd get on the next tier.
Handling Model Deprecations Gracefully
Claude model versions are deprecated periodically. With model names hardcoded in API calls, a deprecation means emergency refactoring. With centralized routing, it means updating two lines.
But you can go further: build deprecation awareness into the routing layer itself.
// lib/model-registry.ts
interface ModelSpec {
id: ClaudeModel;
deprecatedAt?: Date; // When Anthropic officially deprecated it
sunsetAt?: Date; // When the API stops accepting requests
successor?: ClaudeModel; // Automatic upgrade path
costTier: "low" | "mid" | "high";
}
const MODEL_REGISTRY: ModelSpec[] = [
{
id: "claude-haiku-4-5-20251001",
costTier: "low",
// No deprecation — current model
},
{
id: "claude-sonnet-4-6",
costTier: "mid",
},
{
id: "claude-opus-4-6",
costTier: "high",
},
];
export function resolveModel(requested: ClaudeModel): ClaudeModel {
const spec = MODEL_REGISTRY.find((m) => m.id === requested);
if (!spec) return requested;
const now = new Date();
// If deprecated but not yet sunset: log warning and use successor
if (spec.deprecatedAt && now >= spec.deprecatedAt && spec.successor) {
console.warn(
`[Model Deprecated] ${requested} deprecated as of ${spec.deprecatedAt.toISOString()}. ` +
`Auto-upgrading to ${spec.successor}.`
);
return spec.successor;
}
return requested;
}Add resolveModel() as the final step before every API call. When Anthropic announces a deprecation, you update the registry — and your entire service transparently upgrades, with warnings logged so you can track the transition.
When Routing Isn't the Right Answer
Model routing isn't universally applicable. There are service types where it adds complexity without proportional benefit:
Services where all requests are inherently Opus-level: If your value proposition is "the most rigorous legal analysis possible" and every user query is a complex legal question, routing to Haiku for some requests destroys your product. Don't route just to route.
Low-volume services where latency matters more than cost: Haiku classification adds ~200ms before the main call. At 1,000 requests/month, the cost savings don't justify the latency penalty. Route explicitly (pass taskType directly) or skip routing until volume warrants it.
Services with highly unpredictable task types: If your prompt structure means every user request could be anything from "classify this" to "write a 2,000-word legal memo," classification accuracy will be low. Invest in prompt structuring first — route task type through explicit UI controls before relying on LLM classification.
The right time to implement routing is when you have measurable, repeating task patterns and your monthly API cost is at a level where a 50% reduction materially impacts your business. For most solo developers, that inflection point is somewhere around 500,000 requests/month or $300+/month in API costs.
Looking back: The Routing Stack That Works
Putting it all together, here's the complete stack I've validated in production:
- Centralized routing table (
ROUTING_TABLE) — single source of truth, updated when models change - Haiku-based classifier — routes unknown task types at negligible cost
- Tier-aware ceiling — free users get Haiku, pro users get Sonnet, enterprise users get Opus access
- Quality fallback — Haiku evaluates critical responses; failures escalate to Opus automatically
- Prompt caching — compounded 70–90% reduction on repeated system prompt tokens
- Usage logging with
routingReason— debuggable, auditable, Stripe-reportable - Model registry — handles deprecations without emergency code changes
- Weekly health monitoring — Haiku share, fallback rate, cost-per-request trend
Model routing is not a "set it and forget it" optimization. It requires ongoing attention as your user behavior evolves. But the economics justify that attention — at scale, a 50% cost reduction on API spend is often the difference between a profitable service and one that's subsidizing its own users.
For capturing and monetizing per-request costs through usage-based billing, see Designing Usage-Based Billing for Claude API Services with Stripe Metered Billing. For reducing system prompt costs further through caching, see the guide on prompt caching and token cost optimization.