●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
An Anti-Corruption Layer for Claude API Models — Keeping Generation Changes Out of Your Business Logic
Hard-coding model strings into business logic means production breaks quietly every time a generation is retired. Here is an anti-corruption layer that separates logical roles from physical model IDs, with working TypeScript and Python, migration costs, and the judgment calls behind it.
In the spring of 2026, when I received the email announcing that the legacy Haiku 3 model strings were entering deprecation, the first thing I opened was not the API dashboard but a grep across my own app source. The string claude-3-haiku was sitting, hard-coded, in twelve places: the review summarizer of a wallpaper app, the daily push copy of a manifestation app, the auto-draft generator for a personal blog. Every one of them was code I had written years ago meaning only to "get it working," and it had quietly stayed in production ever since.
I have been building apps solo under the name Dolice since 2014, twelve years now, running a portfolio that has passed 50 million cumulative downloads. Once your AdMob revenue reaches a meaningful scale, the scariest failure is the silent one. If something throws, you notice. But a model retirement arrives wearing a different face: responses drift gradually from some day onward, or a 400 starts coming back the moment a deadline passes. What this moment drove home was that pulling a model name — an identifier that changes at an external vendor's convenience — into the inside of my own business logic was itself a design debt.
This article is about paying down that debt with an anti-corruption layer. I take this pattern from domain-driven design, apply it to the Claude API, and show a concrete implementation that separates logical roles from physical model IDs, in working TypeScript and Python.
What "quiet corruption" really is
The real problem with hard-coding model names into business code is not that grep finds twelve hits. If that were all, a single replace would fix it. The deeper problem is that each call site carries the model's implicit assumptions along with the string.
The review-summary code, for example, was written on the assumption that "Haiku is fast but weaker at following long instructions, so keep the prompt short." The blog-draft code, meanwhile, assumed "a little slower is fine because I want quality, so set max_tokens generously." Behind the very same claude-3-haiku string, the expectations differed at every call site.
Run a naive find-and-replace in that state and you drop a high-quality model into latency-sensitive code and blow the latency budget, or drop a lightweight model into quality-sensitive code and watch the output degrade. The model string, in other words, was never just an identifier — it was a symbol that compressed several non-functional requirements: speed, quality, cost, context length. What the anti-corruption layer needs to isolate is exactly that compressed bundle of assumptions.
Designing logical roles as a "translation vocabulary"
At the center of the anti-corruption layer sits a dictionary that translates between the business vocabulary and the API vocabulary. I call the business side "logical roles." The app code knows nothing about physical model names; it requests only the property it actually wants, expressed as a role.
Across my portfolio, collapsing everything into these three roles turned out to be neither too many nor too few.
fast — lightweight tasks that run synchronously with a user action. Latency first. Review-summary previews, input assistance.
balanced — tasks that run asynchronously but whose quality I will see the same day. Push copy generation, tagging.
deep — high-quality tasks that a human will definitely review afterward. Blog drafts, long-form outlines.
The crucial point is that these role names are cut by the nature of the use case, not by a performance ranking like "fast vs. smart." It never happens that "fast eventually wants to be upgraded to deep." fast simply means "a place where I do not want to keep the user waiting," forever. No matter how much smarter the physical model becomes, that boundary stays stable. The reason this abstraction does not rot over long-term operation is that I placed its axis on requirements, not on performance.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A ModelRegistry in both TypeScript and Python that separates logical roles (fast / balanced / deep) from physical model IDs, shrinking a generation change to three lines in one file
✦The real-world rescue of a 50M-download app portfolio from 12 hard-coded model strings during the Haiku 3 retirement and the Sonnet 4.5 1M-context migration, cutting migration effort by 80%
✦A capability contract test that notices a generation change before the retirement date, plus fallback-aware token-cost recalculation
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
ModelRegistry — confining the translation to one place
Concentrate the role-to-physical-model translation into a single module. The goal is to reach a state where a generation change means touching only this file.
// model-registry.ts — the one and only place that confines "external circumstances"export type ModelRole = "fast" | "balanced" | "deep";interface ModelBinding { primary: string; // the physical model ID normally used fallbacks: string[]; // the order to descend through on failure or retirement inputPer1M: number; // USD per 1M input tokens outputPer1M: number; // USD per 1M output tokens maxOutputTokens: number;}// rewriting only this completes a model-generation change across every appconst REGISTRY: Record<ModelRole, ModelBinding> = { fast: { primary: "claude-haiku-4-5-20251001", fallbacks: ["claude-3-5-haiku-latest"], inputPer1M: 1.0, outputPer1M: 5.0, maxOutputTokens: 4096, }, balanced: { primary: "claude-sonnet-4-6", fallbacks: ["claude-haiku-4-5-20251001"], inputPer1M: 3.0, outputPer1M: 15.0, maxOutputTokens: 8192, }, deep: { primary: "claude-opus-4-6", fallbacks: ["claude-sonnet-4-6"], inputPer1M: 15.0, outputPer1M: 75.0, maxOutputTokens: 16384, },};export function resolve(role: ModelRole): ModelBinding { const binding = REGISTRY[role]; if (!binding) throw new Error(`unknown role: ${role}`); return binding;}export function chain(role: ModelRole): string[] { const b = resolve(role); return [b.primary, ...b.fallbacks];}
The point of this design is that the price and the context ceiling are bundled into the role alongside the model. Swap only the model string and the cost calculation or max_tokens assumption (shown below) drifts out of sync, breeding a different bug. Things that change together live together.
Gateway — the only window the business code can see
The business code never calls the Anthropic SDK directly. Everything goes through this gateway, which takes on fallback descent and cost recording in one place.
// model-gateway.ts — business code needs to know only this ask()import Anthropic from "@anthropic-ai/sdk";import { resolve, chain, type ModelRole } from "./model-registry";const client = new Anthropic();interface AskResult { text: string; modelUsed: string; usdCost: number; degraded: boolean; // did we drop below primary?}export async function ask( role: ModelRole, prompt: string, opts: { maxTokens?: number } = {},): Promise<AskResult> { const binding = resolve(role); const candidates = chain(role); let lastErr: unknown; for (let i = 0; i < candidates.length; i++) { const model = candidates[i]; try { const res = await client.messages.create({ model, max_tokens: opts.maxTokens ?? binding.maxOutputTokens, messages: [{ role: "user", content: prompt }], }); const inTok = res.usage.input_tokens; const outTok = res.usage.output_tokens; // fallbacks may have different pricing, so recalculate on the model actually used const usdCost = priceFor(model, inTok, outTok); const text = res.content .filter((b) => b.type === "text") .map((b) => (b as { text: string }).text) .join(""); return { text, modelUsed: model, usdCost, degraded: i > 0 }; } catch (err) { lastErr = err; // treat retirement (404 model_not_found) and overload (529) identically as "descend" continue; } } throw new Error(`all candidates failed for role=${role}: ${String(lastErr)}`);}function priceFor(model: string, inTok: number, outTok: number): number { // a real-model price table not tied to roles (covers fallbacks too) const table: Record<string, [number, number]> = { "claude-haiku-4-5-20251001": [1.0, 5.0], "claude-3-5-haiku-latest": [0.8, 4.0], "claude-sonnet-4-6": [3.0, 15.0], "claude-opus-4-6": [15.0, 75.0], }; const [pin, pout] = table[model] ?? [0, 0]; return (inTok / 1e6) * pin + (outTok / 1e6) * pout;}
The business code ends up like this. No model name appears anywhere.
// review-summary.ts — the caller knows only the roleimport { ask } from "./model-gateway";export async function summarizeReview(text: string) { const { text: summary, degraded } = await ask( "fast", `Summarize the following app review in one sentence:\n${text}`, ); if (degraded) { // on degradation, just log; never stop the user experience console.warn("[review-summary] running on fallback model"); } return summary;}
When the retirement email arrives, the only thing you touch is the single line in model-registry.ts that rewrites primary to the new model ID. In fact, after moving to this design, migrating from the Sonnet 4.5 1M-context family to Sonnet 4.6 was finished with three lines in the registry and one line in the price table. Compared with chasing twelve call sites individually, the actual hands-on time the migration took dropped by roughly 80%.
Drawing the same boundary on the Python side
My pipeline mixes TypeScript and Python. Blog draft generation and Vision-based batch classification of wallpapers live on the Python side. I port the same philosophy directly.
# model_gateway.py — drawing the same boundary in Python as the TypeScript versionfrom dataclasses import dataclassfrom anthropic import Anthropicclient = Anthropic()@dataclass(frozen=True)class Binding: primary: str fallbacks: tuple[str, ...] max_output: intREGISTRY: dict[str, Binding] = { "fast": Binding("claude-haiku-4-5-20251001", ("claude-3-5-haiku-latest",), 4096), "balanced": Binding("claude-sonnet-4-6", ("claude-haiku-4-5-20251001",), 8192), "deep": Binding("claude-opus-4-6", ("claude-sonnet-4-6",), 16384),}def ask(role: str, prompt: str, max_tokens: int | None = None) -> dict: b = REGISTRY[role] candidates = (b.primary, *b.fallbacks) last_err: Exception | None = None for i, model in enumerate(candidates): try: res = client.messages.create( model=model, max_tokens=max_tokens or b.max_output, messages=[{"role": "user", "content": prompt}], ) text = "".join(blk.text for blk in res.content if blk.type == "text") return {"text": text, "model_used": model, "degraded": i > 0} except Exception as err: # treat retirement and overload identically as "next" last_err = err continue raise RuntimeError(f"all candidates failed for role={role}: {last_err}")
Across languages, the invariant "business code knows only the role" stays the same. The one weakness is that the registry now splits into two, so I lean toward extracting the prices and model IDs into a JSON file read by both languages.
A contract test that "notices" a generation change
The biggest trap of abstraction is reaching the retirement date with the registry still un-updated. To prevent that, I put a contract test in CI that confirms each role is still alive.
# test_model_contract.py — ping each role's primary daily to confirm it exists and respondsimport pytestfrom model_gateway import REGISTRY, client@pytest.mark.parametrize("role", list(REGISTRY.keys()))def test_primary_model_alive(role): model = REGISTRY[role].primary res = client.messages.create( model=model, max_tokens=8, messages=[{"role": "user", "content": "ping"}], ) assert res.stop_reason in ("end_turn", "max_tokens")
I run this test on a daily GitHub Actions schedule. When a model is retired and starts returning model_not_found, CI goes red before the retirement date and I notice. Since putting this in place, I stopped relying on the retirement email as my primary signal. A design that does not depend on catching an announcement is far more robust for solo operation.
As an operational judgment, I scope this contract test to "only the primary of fast / balanced / deep." Pinging the fallbacks daily makes cost and runtime non-trivial, and a fallback is by definition one step safer, so monitoring the health of the primary alone already satisfies the goal of early detection.
How far to abstract, and where to stop
An anti-corruption layer is useful, but overdone it breeds a different debt: maintaining your own home-grown framework. Here is the line I draw.
Isolate: model IDs, prices, default max_tokens, fallback order. These "change at external convenience," so they always go inside the layer.
Do not isolate: how features themselves are invoked, such as extended thinking or tool use. Wrap those in a thin wrapper and you create the chore of chasing the SDK's new features with your own wrapper every time one ships — exactly backwards. For the call sites that need them, the pragmatism of using client directly turned out, over the long run, to be easier to maintain.
The goal of abstraction is to keep the blast radius of change small, not to make the SDK invisible. Leave the interface Anthropic provides stably (the basic shape of messages.create) visible, and isolate only the parts that wobble at vendor convenience (the model identifier and the price). This boundary is where I found the best reconciliation with the lesson twelve years of solo development taught me: over-abstraction becomes debt.
If you want to try this next, start by grepping your own codebase for the string claude-. Two or more hits is already a sufficient reason to begin abstracting. I would be glad if this offers a quiet bit of preparation to anyone who has been tossed around by generation changes the way I was.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.