An exit plan is not a migration
These two things get conflated constantly, and the conflation is why so few teams have either. A migration is a project. It has a scope, a budget, an owner and a date, and the date is one you chose because the new thing is better, or cheaper, or the old thing is being wound down on a timetable you had months to absorb. You can staff it, sequence it and cut it short if the quarter goes badly.
An exit plan is not a project. It is a maintained artefact, and its only job is to shorten a migration that somebody else scheduled for you. It is closer in spirit to a backup restore procedure than to a roadmap item: something you keep current, test on a cadence, and hope never to need in anger. The distinction matters because the two are optimised for completely different things. A migration optimises for a good outcome. An exit plan optimises for a fast, known, survivable outcome under conditions you did not pick.
So let us be precise about what "optimises" means here, because the rest of this article is in service of exactly two numbers.
Time-to-switch is the elapsed time between the decision to move and the moment production traffic is being served by the alternate. Not the moment a proof-of-concept works on a laptop; the moment real users are being served. Measure it in hours or days and publish the figure, because an unmeasured time-to-switch is always optimistic by a factor of three.
Capability delta is what measurably gets worse when you do. Not "the other model is nearly as good", which is a vibe. A number, per task, from a suite you trust: pass rate down four points on structured extraction, refusal rate up eleven points on the medical triage flow, p95 latency up 600 milliseconds, cost per request down eighteen per cent. Capability delta is what turns "we could switch" from an assertion into a decision a product owner can sign.
Everything below — the coupling inventory, the adapter, the eval suite, the warm tier, the drill, the contract terms — exists to pull one of those two numbers down. If a piece of work does not, it belongs in a different plan.
Forced migration is now its own risk class
Reliability engineering has always covered the provider being down. What has changed is the frequency with which providers become unavailable to you specifically, while remaining perfectly healthy for everyone else. That is a different failure mode with a different shape: no incident page, no status dashboard, no estimated time to restore, and frequently no technical remedy at all.
The 2026 record is unusually instructive because it produced examples of nearly every variety within a few months of each other. Take them as evidence for the method, not as the method itself — the specific incidents will be forgotten long before the risk class goes away.
Political and procurement risk. On 27 February 2026 the US President directed federal agencies to cease all use of Anthropic technology, with a six-month phase-out, and the Defense Secretary announced a Department of Defense "Supply-Chain Risk to National Security" designation that extended the reach to contractors. Outside analysts questioned whether the statutory authority genuinely stretched that far, and that question may take a long time to resolve. That is precisely the point for a builder: the disruption arrives before the legal question does. Procurement teams start asking for attestations in March; the appellate reasoning lands whenever it lands. Our coverage of the US federal contractor ban goes into the supply-chain designation in detail.
Export-control risk. A US export-control order took Anthropic's Fable 5 and Mythos 5 offline on 12 June 2026. The order was lifted on 30 June 2026 and Fable 5 was restored on 1 July 2026. Eighteen days. That number is worth sitting with, because it is the realistic shape of this risk and it is not the shape most people imagine. Nobody lost a model permanently. Everyone pinned to those two model identifiers lost them for two and a half weeks, which is long enough to blow through a quarter's roadmap and far too long to wait out with a status page and an apology. The Fable 5 and Mythos 5 export-control story has the timeline.
Deprecation risk. OpenAI's o3 was scheduled for retirement from ChatGPT on 26 August 2026, following a ninety-day sunset. This is the most boring entry on the list and the most likely to hurt you, because it is routine, announced well in advance, and completely invisible to a team that pinned a model identifier eighteen months ago and never revisited it. Announced deprecation is the only item here that gives you a generous notice period, and it is the one teams most often waste.
Regulatory risk. EU AI Act obligations arrive on hard dates. Article 50 transparency obligations applied from 2 August 2026; the high-risk obligations for Annex III systems were deferred to 2 December 2027 and for Annex I to 2 August 2028. A regulation does not usually ban a model, but it can make a particular deployment configuration non-viable in a particular market — a provider that will not supply the documentation you need for a conformity file is, functionally, a provider you cannot use for that product line. Our note on the August 2026 deferral covers what is enforceable now, and the practical implementation of the transparency duties is in the guide to shipping Article 50 as code.
Commercial risk. The least dramatic and the most common. Pricing changes, capacity rationing during a launch, a rate-limit tier that stops being granted, contract terms that change on renewal. No press coverage, no legal analysis, just a spreadsheet that stops working.
None of these five categories is addressed by multi-region deployment, retries, or a circuit breaker. Those defend against the provider being unwell. Forced migration is the provider being fine and you being excluded. If your resilience design stops at the retry policy, you have not covered this risk class at all.
Inventory your coupling surface
You cannot plan an exit from a dependency you have not enumerated, and almost every team underestimates the surface because they think of the dependency as "we call the API". The API call is the cheapest part. The expensive coupling is everywhere else — in the shape of your data, the assumptions in your prompts, the schema of your logs and the clauses in your contract.
Work through this table with the actual codebase open, not from memory. The cost column is the one that matters; fill it in with days, not adjectives.
| Coupling point | How you are coupled | Realistic cost to unpick |
|---|---|---|
| SDK and call semantics | Retry behaviour, timeout defaults, streaming iteration model, error taxonomy and which errors are retryable | Low — hours to days, but only if calls are centralised. Days to weeks if the SDK is imported in forty files |
| Tool / function-calling schemas | Dialect differences in how tools are declared, how arguments come back, whether parallel calls are supported and how they are represented | Moderate — a translation layer is a day; discovering the behavioural differences takes considerably longer |
| Structured-output enforcement | Constrained decoding, JSON mode, schema adherence guarantees. These differ in strength, not just in syntax | Moderate to high — a provider with weaker enforcement pushes validation and repair back into your code |
| System prompt and refusal profile | Prompts tuned against one model's instruction-following calibration and safety posture | Moderate — measurable in days of rewriting, but only if you can measure the result at all |
| Tokeniser and context accounting | Chunk sizes, truncation thresholds, budget maths and cost forecasts all assume one tokeniser | Low to moderate — the code change is small; the silent breakage in chunking and budgeting is not |
| Prompt caching semantics | Cache-breakpoint placement, TTL behaviour and the discount structure your unit economics assume | Moderate — and this is where a lowest-common-denominator abstraction quietly costs you money |
| Embedding model and vector index | Every vector in your index lives in one model's vector space and is not transferable to another | Highest — the long pole. Re-embedding the corpus plus an index rebuild plus a dual-read window |
| Fine-tunes and adapters | Trained against a specific base model, hosted by a specific provider, sometimes not exportable at all | High — retraining cost plus data assembly plus revalidation, and possibly no export path |
| Safety-filter behaviour | What gets blocked, at what threshold, with what error, and how your product handles the block | Moderate — usually discovered in production because nobody put it in the eval suite |
| Log and trace schema | Dashboards, alerts and cost attribution keyed to provider-specific field names and usage objects | Low — but it is what makes the first week after a switch feel blind |
| Contractual terms | Data residency commitments, retention windows, training-use carve-outs, capacity guarantees | Variable — cannot be engineered around after the fact; must be negotiated before |
Two observations from filling this in on real systems. First, the embedding row dominates. A generation-model switch is a week of engineering; an embedding-model switch is a backfill programme with a compute bill, a consistency window and a rollback story. The mitigation is architectural and should be decided now: do not let your embedding provider and your generation provider be the same decision. They are separable, they fail for different reasons, and coupling them means a political problem at one vendor turns into an index rebuild.
Second, the contractual row is the only one you cannot fix with engineering effort after the event. Everything above it is a matter of time and money. A missing data-export right is simply missing.
Grep for the provider SDK's import statement and count the files. That number is your first honest coupling metric, and it takes thirty seconds to produce. If it is above five, your time-to-switch is measured in weeks regardless of what your architecture diagram claims.
The abstraction decision, without the dogma
The reflexive advice is "put a gateway in front of it". That is sometimes right and frequently sold with more confidence than the evidence supports. There are three postures, each with a genuine cost, and the correct answer depends on how many teams you have and how much of the provider's advanced surface you actually use.
| Posture | Build cost | Exit cost | The honest downside |
|---|---|---|---|
| Direct SDK, called from anywhere | Lowest — zero | Highest | Full access to every provider feature, and a switch that touches every call site. Fine for a prototype, expensive in year two |
| Thin in-house adapter over a narrow interface | Low — typically under 300 lines | Low to moderate | You own it, including the dialect translation and its bugs. Needs discipline to stop it growing into a framework |
| Third-party gateway (managed or self-hosted) | Moderate — configuration plus operations | Low for providers, moderate for the gateway itself | The gateway is now a dependency with its own outages and its own exit problem. Adds a network hop. Tends toward a lowest-common-denominator feature set |
That last downside deserves expansion, because it is the one that shows up on the invoice rather than in the incident channel. A gateway must expose a surface that works across providers, which means the provider-specific features that are most valuable to you are often the ones it handles least well: prompt-cache breakpoint control, strict structured-output enforcement, reasoning-effort parameters, and the usage fields you need for accurate per-feature cost attribution. Teams routinely adopt a gateway for portability and discover six weeks later that their prompt-cache hit rate collapsed, which is a real and recurring cost. The comparison of LiteLLM, OpenRouter and Portkey is worth reading before committing, particularly on where each sits on the pass-through versus normalisation spectrum.
For most teams, the default should be a thin in-house adapter over the narrow slice of the API you actually use. It is cheap because your slice is small — most production systems use perhaps fifteen per cent of a provider's surface. It keeps provider-specific escape hatches available because you wrote the interface and can add one. And it is the only posture where the abstraction cost is proportional to your own usage rather than to the union of everybody's.
Reach for a managed gateway when the problem you have is organisational rather than architectural: many teams, centralised key management, spend caps and chargeback, a single place to enforce policy. Those are real problems and a gateway solves them well. Portability is a side effect, not the headline benefit.
Here is what a thin adapter looks like when it is honest about where the leaks are. The value of the code below is not the abstraction; it is the comments marking every place where the providers genuinely differ, because those are the lines that will cost you time on switch day.
import json
from dataclasses import dataclass, field
from typing import Any, Iterator, Literal, Protocol
FinishReason = Literal["stop", "length", "tool_call", "filtered", "other"]
@dataclass
class Chunk:
"""Normalized streaming event. Providers do NOT agree on this shape."""
text: str = ""
tool_call_delta: dict[str, Any] | None = None
finish_reason: FinishReason | None = None
@dataclass
class ToolSpec:
name: str
description: str
parameters: dict[str, Any] # JSON Schema, our canonical form
@dataclass
class Result:
text: str
# Canonical form: {"name": str, "arguments": dict}. Anthropic hands you a
# parsed dict; OpenAI hands you a JSON *string*. Normalize at the edge or
# every caller has to know which provider it is talking to.
tool_calls: list[dict[str, Any]] = field(default_factory=list)
finish_reason: FinishReason = "stop"
input_tokens: int = 0
output_tokens: int = 0
cached_input_tokens: int = 0 # LEAK 1: not every provider reports this,
# and "cached" does not mean the same thing.
class Provider(Protocol):
def complete(self, system: str, messages: list[dict],
tools: list[ToolSpec] | None, max_tokens: int) -> Result: ...
def stream(self, system: str, messages: list[dict],
tools: list[ToolSpec] | None, max_tokens: int) -> Iterator[Chunk]: ...
class AnthropicProvider:
def __init__(self, client, model: str):
self.client, self.model = client, model
# LEAK 2: tool dialect. Anthropic takes name/description/input_schema at the
# top level. Translate here, once, rather than in every caller.
def _tools(self, tools):
return [{"name": t.name,
"description": t.description,
"input_schema": t.parameters} for t in tools or []]
# LEAK 3: finish reasons are a different vocabulary per provider.
_FINISH = {"end_turn": "stop", "max_tokens": "length",
"tool_use": "tool_call", "stop_sequence": "stop"}
def complete(self, system, messages, tools, max_tokens):
r = self.client.messages.create(
model=self.model, system=system, messages=messages,
tools=self._tools(tools), max_tokens=max_tokens,
)
text = "".join(b.text for b in r.content if b.type == "text")
calls = [{"name": b.name, "arguments": b.input}
for b in r.content if b.type == "tool_use"]
return Result(
text=text, tool_calls=calls,
finish_reason=self._FINISH.get(r.stop_reason, "other"),
input_tokens=r.usage.input_tokens,
output_tokens=r.usage.output_tokens,
)
def stream(self, system, messages, tools, max_tokens):
# LEAK 4: the stream is a typed EVENT stream. Text arrives inside
# content_block_delta events; tool arguments arrive as partial JSON
# fragments that you must accumulate yourself.
with self.client.messages.stream(
model=self.model, system=system, messages=messages,
tools=self._tools(tools), max_tokens=max_tokens,
) as s:
for event in s:
if event.type == "content_block_delta":
d = event.delta
if getattr(d, "text", None):
yield Chunk(text=d.text)
elif getattr(d, "partial_json", None):
yield Chunk(tool_call_delta={"partial": d.partial_json})
elif event.type == "message_stop":
yield Chunk(finish_reason="stop")
class OpenAIProvider:
def __init__(self, client, model: str):
self.client, self.model = client, model
# Same leak, different shape: tools are nested under a "function" key
# and the schema field is called "parameters", not "input_schema".
def _tools(self, tools):
return [{"type": "function",
"function": {"name": t.name,
"description": t.description,
"parameters": t.parameters}} for t in tools or []]
_FINISH = {"stop": "stop", "length": "length",
"tool_calls": "tool_call", "content_filter": "filtered"}
def complete(self, system, messages, tools, max_tokens):
# LEAK 5: the system prompt is a message with role="system", not a
# separate argument. Any code that reasons about message COUNT breaks.
r = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "system", "content": system}] + messages,
tools=self._tools(tools), max_completion_tokens=max_tokens,
)
m = r.choices[0].message
# LEAK 5b: arguments arrive as a JSON string here and as a dict on
# Anthropic. Parse once, so Result.tool_calls means one thing.
calls = [{"name": c.function.name,
"arguments": json.loads(c.function.arguments or "{}")}
for c in (m.tool_calls or [])]
return Result(
text=m.content or "", tool_calls=calls,
finish_reason=self._FINISH.get(r.choices[0].finish_reason, "other"),
input_tokens=r.usage.prompt_tokens,
output_tokens=r.usage.completion_tokens,
)
def stream(self, system, messages, tools, max_tokens):
# LEAK 6: a flat delta stream, not typed events. Tool arguments arrive
# as string fragments under a separate index. Different accumulation
# code from the block above - this is the single most under-tested
# difference between providers.
s = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "system", "content": system}] + messages,
tools=self._tools(tools), max_completion_tokens=max_tokens,
stream=True,
)
for part in s:
delta = part.choices[0].delta
if delta.content:
yield Chunk(text=delta.content)
for tc in (delta.tool_calls or []):
yield Chunk(tool_call_delta={"index": tc.index,
"partial": tc.function.arguments})
if part.choices[0].finish_reason:
yield Chunk(
finish_reason=self._FINISH.get(
part.choices[0].finish_reason, "other"))
Note what the adapter deliberately does not attempt. It does not normalise prompt-cache control, because the semantics genuinely differ and pretending otherwise loses you the discount. It does not normalise the argument-accumulation logic for streamed tool calls, because that logic is different enough that a shared implementation would be more confusing than two clear ones. An adapter that is honest about its leaks is more useful than one that hides them, because on switch day you want a list of known differences, not a surprise.
The evaluation suite is the exit plan
Here is the central claim, and it is worth stating baldly: without a suite you can point at a candidate provider and run, "we could switch" is an opinion. Everything else in this article is scaffolding around that one capability. The adapter makes the switch mechanically cheap. The eval suite is what tells you whether the switch is acceptable, and it is the only artefact that turns capability delta from an adjective into a number.
This is also why exit plans written by architecture teams tend to fail. They produce a sequence diagram and a runbook. What they do not produce is the thing that answers the only question the business will ask at nine o'clock on the morning of the ban: "if we move, what breaks?"
A suite that can answer that has four components, and the fourth is the one almost everybody misses.
A task-level golden set. Not a prompt library — a set of cases at the level of the job your product does, with inputs drawn from real traffic and expected outputs or assertions attached. Fifty well-chosen cases per critical flow beats five hundred scraped ones. The construction method is set out in the guide to building your first evaluation suite with golden sets and judges, and the rule that matters most is that cases come from production, including the ugly ones.
A judge you have calibrated. An uncalibrated model-as-judge will happily tell you the new provider is fine, particularly if the judge is from the same family as one of the candidates, which is a bias you must actively test for. Calibrate against human labels on a sample, measure agreement, and re-check the calibration when you change judge model. The mechanics, including the family-bias problem, are in the guide to building a reliable LLM-as-a-judge.
Cost and latency measured per task, not per token. Per-token pricing is not comparable across providers, because tokenisers differ, because caching discounts differ, and because reasoning tokens may be billed without ever being shown to you. The only honest unit is cost per completed task and p95 latency per completed task, measured on your own traffic shape.
Refusal and safety-behaviour deltas. This is the commonly missed one and it causes the most production surprise. Two providers with similar benchmark scores can have materially different refusal profiles, and a flow that works fine on one may start declining eight per cent of legitimate requests on the other — a medical-adjacent query, a security research question, a debt-collection letter, content in a language where the safety classifier is weaker. Your golden set must therefore contain a deliberate slice of legitimate-but-sensitive cases, with expected outcome "answers appropriately", and you must report refusal rate as a first-class metric alongside pass rate.
The harness below is the minimum viable version. It runs one golden set against N providers and emits the comparison table that a decision actually needs.
import asyncio, statistics, time
from dataclasses import dataclass
PRICES = { # USD per 1M tokens - re-check against the provider's page before use
"vendor-a-flagship": {"in": 5.00, "out": 25.00},
"vendor-b-flagship": {"in": 3.00, "out": 15.00},
"self-hosted-ow": {"in": 0.45, "out": 0.45}, # amortized GPU cost
}
@dataclass
class CaseResult:
case_id: str
passed: bool
refused: bool
latency_ms: float
cost_usd: float
error: str | None = None
REFUSAL_MARKERS = ("i can't help", "i cannot help", "i'm unable to",
"i can't assist", "against my guidelines")
def looks_like_refusal(text: str) -> bool:
# Crude on purpose. Replace with a calibrated classifier once you have one -
# but ship the crude version today rather than measuring nothing.
low = text.lower()
return any(m in low for m in REFUSAL_MARKERS)
async def run_case(provider, model_key, case, judge, sem) -> CaseResult:
async with sem:
t0 = time.perf_counter()
try:
r = await provider.complete_async(
system=case.system, messages=case.messages,
tools=case.tools, max_tokens=case.max_tokens,
)
except Exception as e:
return CaseResult(case.id, False, False, 0.0, 0.0, error=str(e))
latency_ms = (time.perf_counter() - t0) * 1000
p = PRICES[model_key]
cost = (r.input_tokens * p["in"] + r.output_tokens * p["out"]) / 1_000_000
refused = looks_like_refusal(r.text)
# A refusal on a case whose expectation is "should answer" is a FAILURE,
# not a neutral outcome. Counting it as neutral is how teams ship a
# provider that quietly declines 8% of legitimate traffic.
passed = (not refused) and await judge.passes(case, r)
return CaseResult(case.id, passed, refused, latency_ms, cost)
async def evaluate(providers: dict, golden_set, judge, concurrency: int = 8):
rows = []
for model_key, provider in providers.items():
sem = asyncio.Semaphore(concurrency)
results = await asyncio.gather(*[
run_case(provider, model_key, c, judge, sem) for c in golden_set
])
ok = [r for r in results if r.error is None]
lat = sorted(r.latency_ms for r in ok)
rows.append({
"provider": model_key,
"n": len(results),
"pass_rate": sum(r.passed for r in ok) / max(len(ok), 1),
"refusal_rate": sum(r.refused for r in ok) / max(len(ok), 1),
"p50_ms": statistics.median(lat) if lat else 0,
"p95_ms": lat[int(len(lat) * 0.95)] if lat else 0,
"cost_per_task": sum(r.cost_usd for r in ok) / max(len(ok), 1),
"errors": sum(1 for r in results if r.error),
})
return rows
def render(rows, baseline_key):
base = next(r for r in rows if r["provider"] == baseline_key)
head = f"{'provider':<22}{'pass':>8}{'refuse':>8}{'p95 ms':>9}{'$/task':>10}{'delta':>8}"
print(head); print("-" * len(head))
for r in rows:
delta = (r["pass_rate"] - base["pass_rate"]) * 100
print(f"{r['provider']:<22}{r['pass_rate']:>7.1%}{r['refusal_rate']:>8.1%}"
f"{r['p95_ms']:>9.0f}{r['cost_per_task']:>10.4f}{delta:>+7.1f}pp")
# provider pass refuse p95 ms $/task delta
# -----------------------------------------------------------------
# vendor-a-flagship 94.0% 1.2% 2180 0.0121 +0.0pp
# vendor-b-flagship 90.5% 4.8% 1740 0.0083 -3.5pp
# self-hosted-ow 81.0% 0.9% 3310 0.0027 -13.0pp
That last block of output is the entire point of the exercise. It is a capability delta, in numbers, that a product owner can read in ten seconds and make a decision from. The self-hosted row loses thirteen points of pass rate and costs a quarter as much per task — whether that is acceptable depends on the flow, and now it is a conversation rather than a guess.
Two operational notes. This harness belongs in CI, not in a notebook that one engineer runs occasionally; the setup is covered in the guide to putting evals in CI for prompt and agent regression testing. And it should run against the alternate provider on a schedule even when you have no intention of switching, because a suite that has not been run against the alternate for six months is a suite whose results you do not trust on the day you need them.
Every article here is written by a Verified Builder. Want your name on the next one?
AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.
Become a Verified Builder →Standing capability: how warm does your alternate need to be?
Between "we have a document" and "we run two providers at full cost" there is a spectrum, and choosing a point on it deliberately is most of the value here. Three tiers, with honest costs.
| Tier | What it means | Ongoing cost | Realistic time-to-switch | Choose it when |
|---|---|---|---|---|
| Cold | Credentials exist and an adapter is written. Nothing is exercised against real traffic | Near zero, plus quarterly credential checks | Days to weeks — you will discover the coupling surface during the incident | Non-critical internal tooling; teams below roughly ten people; any flow where a week of degradation is survivable |
| Warm | A shadow slice of real production traffic runs against the alternate continuously. Results are logged and scored, never served | The full inference cost of the shadow slice (typically 1-5% of traffic) plus the logging and comparison pipeline | Hours to a day — the unknowns have already been found | Anything with a revenue path, an availability commitment, or a regulatory exposure. This is the sweet spot for most scale-ups |
| Hot | Live fractional traffic is genuinely served by the alternate — commonly 5-20% | Full inference cost of that fraction, plus two sets of prompts, evals and on-call knowledge to maintain | Minutes — it is a routing weight change | Regulated workloads, contractual availability commitments, or a genuine second-sourcing requirement from procurement |
The rule for choosing is straightforward once the numbers are on the table. Compare the ongoing cost of the tier against the cost of the outage it prevents, using a realistic duration. The 12 to 30 June 2026 export-control episode gives an unusually concrete anchor: eighteen days. Multiply your revenue-at-risk per day by eighteen and compare it to one to five per cent of your inference bill. For most teams with a revenue path, warm wins that arithmetic comfortably. For an internal document-summarisation tool, cold is obviously correct and warm would be waste.
Warm is the tier worth defending, because it is the one that converts unknown unknowns into known ones at a bounded price. A shadow slice exercises the genuine input distribution — the malformed inputs, the mixed-script Hinglish queries, the 40-page PDF nobody put in the golden set — and it does it continuously, so drift shows up as a graph rather than as a discovery. The mechanics of running a shadow slice safely, including how to avoid double-charging users and how to handle side effects, are in the guide to shadow and canary deploys for model upgrades.
"We ran a two per cent shadow slice for four months and thought it was insurance we would never claim. What it actually did was find eleven behavioural differences we would otherwise have found live — including one where the alternate silently truncated a tool argument over about four kilobytes. Finding that in a log at leisure instead of in an incident at midnight was worth the entire cost on its own."
— Anita, Verified Builder · London, UKThere is a fourth option that sits underneath all three and deserves a paragraph of its own: self-hosting an open-weight model as a floor. Its distinguishing property is that it is far harder to cancel politically. A procurement ban, an export-control order or a pricing change does not directly reach weights that are already on disk in your own AWS Mumbai or London account — though it is not absolute protection, since licence terms, future distribution and your own cloud provider all remain third-party dependencies. That is a genuinely different kind of protection from a second commercial vendor, because the two commercial vendors can both be reached by the same policy instrument, as the February 2026 procurement action demonstrated for a whole category of contractors at once.
Be honest about the trade, though. An open-weight model at the size most teams can afford to serve is not fully substitutable for a frontier model — the capability delta is real and, in the illustrative table above, thirteen points of pass rate. It costs more per token than an API at low volume, because you are paying for idle GPU time rather than for tokens; the crossover only arrives at sustained utilisation. And you inherit the operational burden of serving, batching, autoscaling and upgrades. The vLLM production playbook covers what that burden actually looks like. Treat self-hosting as a floor for the flows that must never stop, not as a replacement for the flows that must be excellent.
The drill: the part nobody does
A written plan that has never been executed is fiction. This is not a rhetorical flourish; it is the observed pattern. The document says time-to-switch is four hours. The first real attempt takes three days, and two of them are spent discovering that the alternate provider's account has a rate limit nobody raised, that the tool-argument accumulation code was never tested against the other stream shape, and that the on-call engineer does not have permission to change the routing config.
So schedule a drill. Quarterly for anything with an availability commitment or a regulatory exposure; half-yearly otherwise; and additionally after any change to the adapter, the eval suite or the provider contract.
Scope. One critical flow, end to end, in production or in an environment that is genuinely production-shaped. Not the whole platform — a whole-platform drill gets deferred forever and teaches less than one flow done properly.
Who runs it. The on-call engineer, not the person who wrote the adapter. The plan must work when executed by someone who did not design it, because on the day, it will be. The author observes and takes notes; they do not touch the keyboard.
What is measured. Time-to-switch, wall-clock, from the go signal to real traffic served. Capability delta, from the eval suite, on the flow in scope. Cost delta, per task. And a written list of everything that broke, however small.
What "pass" means. Three things held simultaneously: the switch reached production traffic inside the published time-to-switch target; the capability delta stayed inside the tolerance previously agreed with the product owner; and the cost delta was known in advance rather than discovered. A drill that produced no findings was almost certainly not a real drill.
A four-hour drill on a Thursday afternoon looks roughly like this.
| Elapsed | Activity | Owner | Recorded |
|---|---|---|---|
| T+0:00 | Go signal. Incident channel opened. Timer started | Drill lead | Start timestamp |
| T+0:10 | Full eval suite launched against the alternate on the flow in scope | On-call engineer | Suite version, commit SHA |
| T+0:40 | Results reviewed. Capability delta compared against agreed tolerance. Go / no-go recorded | On-call + product owner | Pass rate, refusal rate, p95, cost per task |
| T+1:00 | Routing shifted to 5% on the alternate. Guard metrics watched | On-call engineer | Time to first served request |
| T+1:30 | Ramp to 25%. First real breakages surface here in practice | On-call engineer | Every error, however trivial |
| T+2:30 | Ramp to 100% on the flow in scope. Hold and observe | On-call engineer | Time-to-switch stopped here |
| T+3:00 | Hold at 100% for a full traffic-shape window, covering both the IST and BST peaks if the product serves both | On-call engineer | Latency and cost under real load |
| T+3:30 | Roll back to the incumbent. Rollback is itself part of the drill and is timed | On-call engineer | Rollback duration |
| T+4:00 | Debrief. Findings written up, owners assigned, target times revised | Drill lead | Findings list, updated one-pager |
What a real drill looks like, as an illustrative composite rather than a report on any one named team. The on-call engineer — who did not write the adapter — is handed the one-pager and told to move the support-summarisation flow to the alternate provider. Time-to-switch came in at three hours forty minutes against a four-hour target. Six findings: the alternate account's tier-2 rate limit had never been raised, the tool-argument accumulator dropped fragments above four kilobytes, two dashboards keyed on a provider-specific usage field went blank, the runbook named an engineer who had left, refusal rate on the sensitive-but-legitimate slice was up 3.1 points, and cost per task was 22 per cent lower than modelled because the modelling used list price. All six were fixed within a fortnight. The next drill came in at two hours ten.
A tabletop that proves nothing. Six people in a room for ninety minutes talking through the runbook, agreeing it looks sound, and recording "exit plan validated" in the risk register. No credentials were used, no traffic moved, no eval ran, and the rate limit that would have blocked the whole thing at T+1:10 remained unraised for another eleven months. Tabletops are useful for aligning people on a decision tree. They are worthless for measuring time-to-switch, and citing one as evidence of readiness is worse than having no plan, because it converts an open risk into a closed one on paper.
One more thing to test in the drill that teams forget: your observability. If your traces, cost attribution and quality dashboards are keyed to provider-specific field names, the first hour after a real switch is spent flying blind precisely when you most need instruments. Normalising span attributes across providers, as described in the guide to instrumenting agents with OpenTelemetry, is cheap insurance and is easiest to verify during a drill.
Contract and data terms to negotiate before you need them
Engineering can shorten time-to-switch. It cannot manufacture a right you did not negotiate. These are the clauses worth spending renewal leverage on, and the time to raise them is at signature or renewal, never during the incident.
| Term | What to ask for | Why it changes your exit plan |
|---|---|---|
| Deprecation notice period | A contractual minimum notice for retiring any model you are pinned to, in writing, with a named channel | Converts deprecation from a surprise into a scheduled migration. The 90-day sunset pattern seen with o3's 26 August 2026 retirement is generous; do not assume it is universal |
| Data export and deletion | Right to export your data and any derived artefacts in a usable format, and a bounded deletion window on termination | Without an export right, leaving means abandoning assets. This is the one clause with no engineering workaround |
| Fine-tuned artefacts | Explicit ownership, and where possible the right to export weights or adapters you paid to train | Determines whether a switch costs a retraining run or a total loss. Frequently the largest single line in a forced migration |
| Residency commitments | Named processing regions, named sub-processors, and notice before either changes | A region change can break a compliance posture without any code changing. Route accordingly — see the DPDP and GDPR routing guide |
| Capacity and rate limits | Committed throughput floors, and what happens to them during a provider-wide capacity crunch | Rationing is a soft outage. A best-efforts limit is not a plan for a launch |
| Training-use carve-outs | Written confirmation that your inputs and outputs are not used for model training, with a retention window | Often the specific term that makes one provider usable for a regulated flow and another not |
| Assignment and change of control | Notice and, ideally, termination rights if the provider is acquired or restructured | Your counterparty on renewal may not be the one you signed with |
Three regional notes for a team operating across India and the UK, because the obligations do not line up neatly.
For Indian deployments, the Digital Personal Data Protection Act framework makes the export and deletion clauses do double duty: you need to be able to honour an erasure request downstream of a provider you have left, which means knowing what they retained and for how long, in writing, before you leave. Name the processing region — AWS Mumbai, for instance — in the contract rather than relying on a console setting that a future platform change might quietly alter.
For UK deployments, UK GDPR and the ICO's expectations put weight on documented decision-making. The exit plan itself is useful evidence here: a maintained artefact showing you assessed processor concentration risk is a better answer to a supervisory question than an assurance that you would have coped. Name the London region explicitly for the same reason you named Mumbai.
And the one that catches people out: an EU-facing deployment inherits EU AI Act obligations regardless of where the company sits. A Bengaluru or Manchester company serving EU users is inside the scope. Since Article 50 transparency obligations applied from 2 August 2026, with the high-risk obligations deferred to 2 December 2027 for Annex III and 2 August 2028 for Annex I, the provider-facing question is concrete: can this vendor supply the documentation you need for your conformity file, and will they still be able to when the deferred dates arrive? A vendor who cannot is a vendor you will be exiting on a schedule set by the calendar.
Do not assume your exit plan survives a change of deployment channel. The same model accessed through a cloud marketplace, through a hyperscaler's managed service and through the vendor's own API can carry different terms, different regions, different rate limits and different deprecation timelines. If your alternate is "the same model on a different cloud", verify that the clauses above actually travel with it. Frequently they do not.
A tiered plan, proportionate to your team
Telling a three-person startup to run a warm shadow tier is bad advice, and it is the reason most exit-plan guidance gets ignored. What follows is proportionate.
| 3-person startup | 30-person scale-up | Regulated enterprise | |
|---|---|---|---|
| Abstraction | One module, one call site. No framework | Thin in-house adapter, two implementations, owned by the platform team | Adapter plus a gateway for key management, spend control and policy enforcement |
| Eval suite | 20-50 golden cases, runnable by one command | Per-flow golden sets in CI, calibrated judge, refusal slice included | Versioned suites with sign-off, retained results, evidence trail for audit |
| Alternate tier | Cold. Credentials live and tested quarterly | Warm on revenue-path flows, cold elsewhere | Hot on regulated flows, warm elsewhere, self-hosted floor for must-never-stop paths |
| Drill cadence | Half-yearly, 2 hours, one flow | Quarterly, 4 hours, rotating flow | Quarterly per critical system, with written findings and remediation owners |
| Contract work | Read the terms once. Note the deprecation policy | Negotiate notice, export and residency at renewal | Full clause set, second-sourcing requirement, change-of-control rights |
| Target time-to-switch | Under 5 days, and known | Under 1 day | Under 1 hour on hot flows |
| Realistic annual cost | ~2 engineer-days | ~15 engineer-days plus 1-5% of inference spend | Programme-level, budgeted with compliance |
Note the pattern down the columns: the startup's entire investment is roughly two days a year, and it buys the single most valuable thing on the list, which is knowing your time-to-switch. The scale-up's step change is the warm tier and CI integration. The enterprise's step change is evidence — the same activities, but documented well enough to show a regulator.
Here is the one-pager. Keep it in the repository next to the adapter, not in a wiki nobody opens, and update it at every drill.
# EXIT PLAN - <service name> last reviewed: YYYY-MM-DD
## 1. Current dependency
provider / model id : <exact identifier you are pinned to>
deployment channel : <direct API | cloud marketplace | managed service>
processing region(s) : <e.g. AWS Mumbai (ap-south-1), London (eu-west-2)>
contractual notice on deprecation : <N days | none>
## 2. Designated alternate
provider / model id : <exact identifier>
tier : cold | warm | hot
credentials verified on : YYYY-MM-DD (re-verify quarterly)
known capability delta : pass <-N.Npp> refusal <+N.Npp> p95 <+Nms> cost <-N%>
## 3. Floor option
self-hosted open-weight : <model, where served, or "none">
covers these flows : <list, or "none">
## 4. Coupling surface - non-trivial items only
embeddings + index : <same provider? re-embed cost + backfill window>
fine-tunes / adapters : <exportable? retrain cost?>
structured output : <enforcement mechanism; what weakens on the alternate>
prompt caching : <discount assumed in unit economics; does it survive?>
## 5. Switch procedure
owner (role, not person) : <e.g. on-call platform engineer>
decision authority : <who says go>
routing change : <exact config key / flag / command>
rollback : <exact steps + expected duration>
target time-to-switch : <hours> last measured: <hours, on YYYY-MM-DD>
## 6. Drill log
YYYY-MM-DD time-to-switch <h:mm> findings <n> open <n>
YYYY-MM-DD ...
## 7. Prompt portability
See the prompt-porting method - not duplicated here:
/tips/port-prompt-suite-across-vendors-claude-gpt-gemini-2026
That final section is deliberate. Rewriting the prompts themselves — the four classes of incompatibility, the order to rewrite in, how to read a per-case regression report — is a discipline of its own and it is covered properly in the guide to porting a prompt suite across vendors. This article is the layer above: the operational discipline that decides whether, how fast and at what measured cost you can invoke that work at all. Keep the two separate and each stays maintainable.
Start with the two numbers
If you do nothing else this quarter, do this. Open your codebase and count the files importing the provider SDK. Write down, honestly, what you think your time-to-switch is. Then run your golden set against one alternate provider and write down the capability delta. That is perhaps a day of work, and at the end of it you will know two numbers that almost nobody in this industry knows about their own system.
Everything else is a refinement. The adapter reduces the first number. The warm tier reduces it further. The drill tells you whether either claim is true. The contract terms determine what is even possible. But the discipline starts with measuring, because a risk you have not quantified is one you are carrying at an unknown price.
And it is unusually good proof-of-work. "We are not locked in" is a claim anyone can put on a slide. A repository containing a provider adapter with its leaks documented, an eval harness that emits a comparison table, and a drill log with times going down quarter on quarter is a claim that verifies itself. If you have built one, put it somewhere the people hiring for platform and reliability roles across Bengaluru, Chennai, London and Manchester can actually find it.