Switching between model providers
Switching model providers means changing which company answers your requests while your own interface stays the same, the same way keeping your phone number while changing network operators means callers never notice the switch underneath.
- 12 min read
- 3 reading levels
- Published
Read these first
On this page 8
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
Switching model providers means changing which company's model actually answers your requests, while keeping your own interface the same for everything that calls it.
The analogy you have already lived
You have kept your phone number while moving from one mobile network to another — a process many people in India know as number portability. The number everyone has saved for you never changed. What changed is invisible to them: a different company's towers, a different network, sometimes different call quality in certain areas. The number they dial stays exactly the same.
Switching model providers is that same portability, applied to code. Your application calls its own function — get_answer(question) — and that function's shape never changes for the callers depending on it. What changes underneath, invisibly to them, is which company's model actually generates the answer.
Why it exists
Companies building on a model provider's API face a real risk. Prices change, rate limits tighten, a model gets deprecated, or a competitor's model becomes decisively better for the task. Every part of the codebase might call one provider's API directly, by name. Being unable to move because of that is exactly the "vendor lock-in" this pattern exists to avoid.
The fix is the same one behind versioning a model API: keep your own contract stable, and let the thing behind it change. Build one shared shape every provider adapter must produce. Switching then becomes changing which adapter is plugged in, not rewriting every place in the codebase that ever asked a question.
How it works
your application code
|
v
your OWN interface -- never changes: get_answer(question) -> {text, cost, ...}
|
v
ONE adapter is currently plugged in behind it
|
+-----+-----+
| |
Provider A Provider B <- swapping which one is plugged in
(today) (candidate) is a config change, not a rewriteA real example you have seen
A company's customer-support chatbot can switch which AI company powers its replies without changing most of the website's code. The chat widget, the conversation history, the escalation logic all stay exactly as they were. Only the one place that actually calls out to generate a reply changes underneath.
The honest part
Two providers rarely behave identically, even when both look correct. Different word counts, different tone, different edge-case handling. Portability only removes the interface problem. Deciding whether the new provider's actual answers are good enough is still real work, covered in the developer section below.
Remember this
- Keep your own interface stable; let the provider behind it be the thing that changes.
- A shared shape (
text,cost,tokens used, and so on) is what makes swapping a config change instead of a rewrite. - Different providers behave differently in real ways — cost, speed, and the answers themselves — and all three need checking before you switch for real.
What to learn next
- Surviving a provider outage — what the same abstraction built here buys you when a provider goes down unexpectedly, not on purpose.
- Cutting LLM API costs — a deeper look at the cost side of exactly the comparison built in this lesson.
- Champion and challenger models — the general pattern this lesson's provider comparison is a specific, real-world instance of.
Developer — Code and libraries.
Setup
pip install pytestNo API keys needed — the two "providers" below are local stand-ins with genuinely different behaviour (different simulated tokenizers, different pricing, different latency), the same way two real providers differ. Swap either one for a real API call; the shared interface does not change.
One shared shape, two different providers behind it
"""Two simulated LLM providers, standing in for two real ones, behind one
shared interface -- so the calling code never needs to know which is behind it.
"""
import random
import time
from dataclasses import dataclass
@dataclass
class NormalizedResponse:
text: str
tokens_used: int
cost_usd: float
latency_ms: float
provider: str
def _fake_tokenize_provider_a(text: str) -> int:
"""Provider A: counts tokens roughly as whitespace-split words."""
return max(1, len(text.split()))
def _fake_tokenize_provider_b(text: str) -> int:
"""Provider B: counts tokens roughly as groups of 4 characters -- a
genuinely different tokenizer, the way real providers differ.
"""
return max(1, len(text) // 4)
class ProviderA:
name = "provider-a"
price_per_1k_tokens = 0.002
def generate(self, prompt: str, seed: int | None = None) -> NormalizedResponse:
time.sleep(0.01)
reply_text = f"[A] Here is an answer about {prompt[:30]}, in a few steps."
tokens = _fake_tokenize_provider_a(prompt) + _fake_tokenize_provider_a(reply_text)
return NormalizedResponse(
text=reply_text, tokens_used=tokens,
cost_usd=round(tokens / 1000 * self.price_per_1k_tokens, 6),
latency_ms=10.0, provider=self.name,
)
class ProviderB:
name = "provider-b"
price_per_1k_tokens = 0.0009
def generate(self, prompt: str, seed: int | None = None) -> NormalizedResponse:
time.sleep(0.025)
reply_text = f"Sure, regarding {prompt[:30]}: here's a short answer."
tokens = _fake_tokenize_provider_b(prompt) + _fake_tokenize_provider_b(reply_text)
return NormalizedResponse(
text=reply_text, tokens_used=tokens,
cost_usd=round(tokens / 1000 * self.price_per_1k_tokens, 6),
latency_ms=25.0, provider=self.name,
)
prompt = "how do I reset my account password"
print("provider A:", ProviderA().generate(prompt))
print("provider B:", ProviderB().generate(prompt))provider A: NormalizedResponse(text='[A] Here is an answer about how do I reset my account pass, in a few steps.', tokens_used=24, cost_usd=4.8e-05, latency_ms=10.0, provider='provider-a') provider B: NormalizedResponse(text="Sure, regarding how do I reset my account pass: here's a short answer.", tokens_used=25, cost_usd=2.3e-05, latency_ms=25.0, provider='provider-b')
Both return the exact same NormalizedResponse shape — text, tokens_used, cost_usd, latency_ms, provider — even though their internal tokenizers count the identical prompt differently (24 tokens against 25, on text that is genuinely not the same length in either case). This is the real reason a "cost per request" comparison across two providers needs care: a token is not a token, in a way that is easy to miss until you compare them side by side.
Comparing the two, on the questions that actually matter
"""Runs the same real test prompts through both providers and reports the
differences that actually matter before switching: cost, latency, and
whether the new provider's answers still pass the same structural checks.
"""
from providers import ProviderA, ProviderB
TEST_PROMPTS = [
"how do I reset my account password",
"what is your refund policy",
"how do I cancel my subscription",
"why was my payment declined",
"how do I update my delivery address",
]
def structural_checks_pass(response) -> bool:
return (
len(response.text) > 10
and len(response.text) < 400
and response.tokens_used > 0
)
current, candidate = ProviderA(), ProviderB()
total_cost_current = total_cost_candidate = 0.0
total_latency_current = total_latency_candidate = 0.0
for prompt in TEST_PROMPTS:
r_current = current.generate(prompt)
r_candidate = candidate.generate(prompt)
total_cost_current += r_current.cost_usd
total_cost_candidate += r_candidate.cost_usd
total_latency_current += r_current.latency_ms
total_latency_candidate += r_candidate.latency_ms
print(f"{prompt!r}: current {r_current.tokens_used}tok/${r_current.cost_usd:.6f} "
f"candidate {r_candidate.tokens_used}tok/${r_candidate.cost_usd:.6f}")
n = len(TEST_PROMPTS)
print(f"total cost -- current: ${total_cost_current:.6f} candidate: ${total_cost_candidate:.6f}")
print(f"cost change: {(total_cost_candidate/total_cost_current - 1) * 100:+.1f}%")
print(f"avg latency -- current: {total_latency_current/n:.1f} ms candidate: {total_latency_candidate/n:.1f} ms")'how do I reset my account password': current 24tok/$0.000048 candidate 25tok/$0.000023 'what is your refund policy': current 20tok/$0.000040 candidate 22tok/$0.000020 'how do I cancel my subscription': current 22tok/$0.000044 candidate 24tok/$0.000022 'why was my payment declined': current 20tok/$0.000040 candidate 22tok/$0.000020 'how do I update my delivery address': current 24tok/$0.000048 candidate 25tok/$0.000023 total cost -- current: $0.000220 candidate: $0.000108 avg latency -- current: 10.0 ms candidate: 25.0 ms
This is a real, complete migration decision in miniature. The candidate provider is roughly 51% cheaper on this batch of prompts, and roughly two and a half times slower per request. Neither of those numbers is "the answer" — they are exactly the trade-off a team actually has to weigh, made visible instead of guessed at.
Testing that both sides of the swap honour the same contract
from compare_providers import structural_checks_pass
from providers import NormalizedResponse, ProviderA, ProviderB
def test_both_providers_return_the_same_normalized_shape():
a = ProviderA().generate("test prompt")
b = ProviderB().generate("test prompt")
assert isinstance(a, NormalizedResponse)
assert isinstance(b, NormalizedResponse)
assert set(vars(a).keys()) == set(vars(b).keys())
def test_both_providers_pass_the_same_structural_checks():
a = ProviderA().generate("how do I reset my password")
b = ProviderB().generate("how do I reset my password")
assert structural_checks_pass(a)
assert structural_checks_pass(b)
def test_cost_is_always_a_small_positive_number():
for provider in (ProviderA(), ProviderB()):
response = provider.generate("some prompt")
assert 0 < response.cost_usd < 0.01pytest test_providers.py -q... [100%] 3 passed in 0.13s
These are exactly the structural checks from testing nondeterministic output, applied across two providers instead of across many samples from one. The same principle carries over unchanged: check the rules the output must obey, not one exact expected string — doubly true when the two things you are comparing are not even the same model.
Common mistakes
Calling a provider's SDK directly from many places in the codebase. Every one of those call sites becomes a place that has to change on a migration. One adapter, called everywhere else through your own interface, turns a codebase-wide rewrite into a one-file change.
Comparing cost per request without comparing tokens per request. As shown above, the same prompt can tokenize to a different count on two providers. A lower price per token does not automatically mean a lower cost per request — compute the actual cost on real prompts, as compare_providers.py does, rather than trusting the headline per-token price alone.
Switching on cost and latency alone, without checking answer quality. The structural checks here catch shape problems. They say nothing about whether the candidate's actual answers are as good, as safe, or as on-brand as the current provider's — that needs the behavioural tests and, ideally, a period of shadow deployment or a canary against real traffic before a full cutover.
No fallback if the new provider has an outage. A single-provider design, even behind a clean interface, is still a single point of failure. See surviving a provider outage for the case where you keep both adapters live, ready to fail over.
Assuming rate limits and failure modes transfer. Retry logic, timeout values, and error handling tuned for one provider's specific quirks may be wrong for another's. Review these explicitly on a switch, rather than assuming what worked before still applies.
Try it yourself
Add a third field to the comparison, tokens_per_character (tokens_used / len(prompt)), computed for both providers across all five test prompts. This makes concrete exactly how differently two tokenizers can treat the same text — a number every real cost estimate for a provider switch should be built on, not guessed at.
What to learn next
- Surviving a provider outage — what the same abstraction built here buys you when a provider goes down unexpectedly, not on purpose.
- Cutting LLM API costs — a deeper look at the cost side of exactly the comparison built in this lesson.
- Champion and challenger models — the general pattern this lesson's provider comparison is a specific, real-world instance of.
Researcher — Mathematics and papers.
The interface as a stable abstraction over an unstable dependency
This lesson applies the dependency inversion principle directly: application code depends on an abstraction (NormalizedResponse, and an implicit generate(prompt) -> NormalizedResponse protocol), not on a concrete provider. Each provider adapter depends on that same abstraction, satisfying it in its own way. The practical payoff, shown in the developer section, is that the comparison itself becomes trivial to write once both sides share a shape — the interface is not only an engineering convenience for switching, it is what makes rigorous, apples-to-apples evaluation possible in the first place.
Tokenization as a genuine source of behavioural difference, not only a cost difference
Different providers' tokenizers are not only an accounting detail. Context-window limits, few-shot example budgets, and truncation behaviour are all defined in units of the provider's own tokens — a prompt that fits comfortably within one provider's context window can silently truncate on another, at the exact same character length. This is a well-documented source of quality regression during a provider migration that a pure cost/latency comparison, as built above, does not surface at all; it requires explicitly checking prompts near your actual context-length ceiling on both sides.
Migration as a special case of champion/challenger
A provider switch fits naturally into the champion/challenger framework from earlier in this section, with an added wrinkle: the challenger is not a retrained version of the same model family, but a structurally different system, potentially with different failure modes, different safety behaviour, and different alignment properties entirely. The statistical discipline (minimum sample size before trusting an apparent difference, guardrail metrics beyond the primary one) transfers directly; the domain-specific evaluation — does this provider handle the same edge cases, the same adversarial inputs, the same safety-sensitive requests, acceptably — does not, and needs its own dedicated evaluation suite per migration.
Switching costs beyond the interface
Even with a clean abstraction, a provider switch commonly carries costs the interface does not capture: prompt engineering tuned to one model's specific response patterns rarely transfers unchanged; fine-tuned or few-shot examples calibrated against one provider's behaviour may need re-tuning; and monitoring baselines (typical latency, typical token counts, typical cost per user) all reset the moment the underlying provider changes, temporarily reducing the sensitivity of drift and anomaly detection until a new baseline is established.
Papers and prior art
- Martin, The Dependency Inversion Principle, 1996 — the general software-design principle this lesson's shared
NormalizedResponseinterface is a direct application of. - Fowler, StranglerFigApplication, martinfowler.com — the general pattern of incrementally migrating a system behind a stable interface, applicable to a gradual provider migration rolled out via the canary or feature-flag mechanisms earlier in this section.
What to learn next
- Surviving a provider outage — what the same abstraction built here buys you when a provider goes down unexpectedly, not on purpose.
- Cutting LLM API costs — a deeper look at the cost side of exactly the comparison built in this lesson.
- Champion and challenger models — the general pattern this lesson's provider comparison is a specific, real-world instance of.