Releasing Models Safely

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.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. How it works
  5. A real example you have seen
  6. The honest part
  7. Remember this
  8. What to learn next

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 rewrite

A 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

Developer — Code and libraries.

Setup

bash
pip install pytest

No 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

providers.py
"""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))
Output
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

compare_providers.py
"""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")
Output
'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

test_providers.py
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.01
bash
pytest test_providers.py -q
Output
...                                                                      [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

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 NormalizedResponse interface 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

What to learn next

These follow on from what you just read.

  • Monitoring Models in Production

    Measuring data drift

    Data drift is when the inputs a live model sees stop looking like the inputs it was trained on, and PSI is the simplest number that tells you how much.

  • Monitoring Models in Production

    Concept drift

    Concept drift is when the correct answer for the same input quietly changes, so a model can look perfectly calibrated on its inputs and still be wrong.

  • Monitoring Models in Production

    Training-serving skew

    Training-serving skew is when the same real event produces a different feature value depending on which code computed it, so a model gets a question it was never trained to answer.