I rolled out a brand‑new LLM‑powered help desk bot on a Friday. By Monday the ops team was screaming about “rate limit exceeded” errors, the latency spiked to 7 seconds, and our cost dashboard was flashing red. The root cause? A hard‑coded OpenAI client that refused to fall back when the API throttled. I had to rebuild the whole thing in a single night, adding retries, a fallback to Claude, and a tiny abstraction layer. The night‑mare taught me a brutal truth: **without a pluggable model strategy, your AI agent is a single point of failure**.

⚡ TL;DR — Key takeaways
  • Define a clean Provider interface with async methods and a rich error hierarchy.
  • Use the Strategy Pattern to swap OpenAI, Anthropic, or local Llama models at runtime.
  • Wrap calls in Tenacity retries, circuit breakers, and OpenTelemetry spans.
  • Build a Context Builder that respects each model’s token limits and pricing.
  • Benchmark the abstraction layer; the overhead is usually < 5 ms.

Before you start: Python 3.12+, Pydantic v2, LangChain v0.2+, asyncio, Tenacity ≥ 9.0, OpenTelemetry SDK 1.26, and API keys for OpenAI, Anthropic, and a local Llama cpp build.

Strategy Pattern for Pluggable AI Model Providers

The Strategy Pattern decouples an AI agent’s core logic from specific LLM providers (OpenAI, Anthropic). By defining a standard provider interface, you can plug in, swap, or chain models at runtime. This enables critical production features like cost optimization, automatic fallback, and resilience against API outages, future‑proofing your agents for 2026’s diverse model landscape.

Why a Pluggable Model Strategy is Critical for AI Agents in 2026

The Rise of Multi‑Modal and Specialized Models

2026 isn’t just about bigger text‑only LLMs. Companies ship vision‑language hybrids, audio‑to‑text transformers, and domain‑tuned specialists (e.g., code‑only Claude 3.5 Sonnet). Your single‑provider client can’t talk to a model that expects an image tensor alongside a prompt. A strategy layer lets you route each modality to the right endpoint without rewriting business logic.

Business Demands: Cost, Latency, and Reliability Trade‑offs

A 2024 AI Infrastructure Alliance survey found **73 %** of teams struggle with vendor lock‑in and model‑switching costs. In production those numbers translate to dollars: GPT‑4 Turbo costs $0.03 / 1k tokens, Claude 3.5 Sonnet is $0.02, while a locally hosted Llama‑cpp costs almost nothing but adds CPU load. With a pluggable layer you can route cheap, low‑latency requests to the local model and only fall back to the pricey cloud endpoint when the prompt exceeds the local context window.

Core Concepts: The Strategy Pattern for AI Systems

Mapped to AI Components: Provider, Model, Context

ConceptTraditional CodeStrategy‑Pattern Mapping
Provider`openai.ChatCompletion.create(…)``ProviderInterface`
Modelhard‑coded model name string`provider.select_model()`
Contextmanual token trimming`ContextBuilder` per provider

The pattern isolates **Provider** (the transport + auth), **Model** (the specific LLM), and **Context** (prompt shaping). Each piece can evolve independently.

Beyond Basic Abstraction: Adding Lifecycle Hooks

A naïve interface only exposes `generate(prompt)`. Production needs hooks for **on_start**, **on_success**, **on_failure**, and **on_cleanup**. These are where you inject telemetry, token budgeting, and cleanup of temporary files (e.g., image buffers for a vision model).

**My take:** Most open‑source LLM wrappers stop at `invoke()`. I push the hooks into the base class because they let you enforce SLAs uniformly—no more “I forgot to add a timer in my Anthropic client”.

Architecture Deep Dive: 2024‑2026 Best Practices

The Clean Contract: Defining the Provider Interface

We rely on Python’s `ABC` module. The contract lives in a separate `provider.py` file, making it import‑friendly for LangChain or LlamaIndex extensions.

# provider.py  # python 3.12
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from pydantic import BaseModel, Field

class ProviderError(RuntimeError): ...
class RateLimitError(ProviderError): ...
class ContextLengthError(ProviderError): ...

class PromptContext(BaseModel):
    """Encapsulate model‑specific token limits and pricing."""
    max_tokens: int = Field(..., description="Hard limit for the target model")
    cost_per_1k: float = Field(..., description="USD per 1 000 tokens")
    truncated: bool = Field(False, description="Did we trim the prompt?")

class ProviderInterface(ABC):
    @abstractmethod
    async def generate(
        self,
        messages: List[Dict[str, str]],
        *,
        temperature: float = 0.7,
        max_tokens: int | None = None,
        stream: bool = False,
    ) -> Dict[str, Any]:
        """Return the raw provider response."""
        ...

    @abstractmethod
    async def build_context(self, raw_prompt: str) -> tuple[PromptContext, List[Dict[str, str]]]:
        """Apply token trimming, summarisation, or multimodal packing."""
        ...

    # Lifecycle hooks -------------------------------------------------
    async def on_start(self, ctx: PromptContext) -> None: ...
    async def on_success(self, response: Dict[str, Any]) -> None: ...
    async def on_failure(self, exc: ProviderError) -> None: ...
    async def on_cleanup(self) -> None: ...

*Why Pydantic v2?* It gives us fast validation and a clean `json()` method that later feeds into OpenTelemetry attributes.

Handling Non‑Functional Requirements in the Contract (Latency, Token Limits)

The `generate` method accepts an optional `max_tokens`. Implementations must **raise** `ContextLengthError` if the supplied prompt exceeds the model’s hard limit. Latency SLAs are enforced in `on_start` via a timeout wrapper (see the Tenacity section later).

“Netflix’s ML platform team reported a 40 % reduction in 95th percentile latency for content recommendation agents by implementing a dynamic model routing layer that could failover from GPT‑4 to Claude or a local model based on real‑time performance and error rates.” – *Netflix Tech Blog, 2024*

Production‑Grade Implementation with Real Code

The Base Provider Class (with Proper Error Hierarchy)

# base_provider.py  # python 3.12
import asyncio
from typing import List, Dict, Any
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from opentelemetry import trace
from .provider import ProviderInterface, ProviderError, RateLimitError, ContextLengthError, PromptContext

tracer = trace.get_tracer(__name__)

class BaseProvider(ProviderInterface):
    def __init__(self, api_key: str, timeout: float = 5.0):
        self.api_key = api_key
        self.timeout = timeout

    async def on_start(self, ctx: PromptContext) -> None:
        self._span = tracer.start_as_current_span("provider.generate")
        self._span.set_attribute("model.max_tokens", ctx.max_tokens)
        self._span.set_attribute("model.cost_per_1k", ctx.cost_per_1k)

    async def on_success(self, response: Dict[str, Any]) -> None:
        self._span.set_attribute("response.tokens_used", response.get("usage", {}).get("total_tokens", 0))
        self._span.end()

    async def on_failure(self, exc: ProviderError) -> None:
        self._span.record_exception(exc)
        self._span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
        self._span.end()

    async def on_cleanup(self) -> None:
        # No-op for most providers; kept for symmetry.
        ...

    @retry(
        reraise=True,
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=0.5, min=0.5, max=4),
        retry=retry_if_exception_type((RateLimitError, asyncio.TimeoutError)),
    )
    async def _call_with_timeout(self, coro):
        return await asyncio.wait_for(coro, timeout=self.timeout)

    async def generate(self, messages: List[Dict[str, str]], **kwargs) -> Dict[str, Any]:
        # Concrete children implement _raw_call()
        raise NotImplementedError("_raw_call must be implemented")

The retry decorator uses **Tenacity** (v9+). We deliberately retry *only* on rate‑limit and timeout errors—everything else bubbles up to the fallback chain.

Concrete Implementations: OpenAI, Anthropic, Local Llama.cpp

# openai_provider.py  # python 3.12
import json
import httpx
from .base_provider import BaseProvider
from .provider import ContextLengthError, PromptContext

class OpenAIProvider(BaseProvider):
    async def build_context(self, raw_prompt: str) -> tuple[PromptContext, List[Dict[str, str]]]:
        # OpenAI's gpt‑4‑turbo supports 128k tokens.
        max_tokens = 128_000
        cost = 0.03  # $ per 1k tokens
        # Simple truncation (real world: use tiktoken summariser)
        trimmed = raw_prompt[:max_tokens * 4]  # rough char‑to‑token estimate
        ctx = PromptContext(max_tokens=max_tokens, cost_per_1k=cost, truncated=len(trimmed) < len(raw_prompt))
        messages = [{"role": "user", "content": trimmed}]
        return ctx, messages

    async def _raw_call(self, messages: List[Dict[str, str]], **kwargs) -> Dict[str, Any]:
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            resp = await client.post(
                "https://api.openai.com/v1/chat/completions",
                headers={"Authorization": f"Bearer {self.api_key}"},
                json={
                    "model": "gpt-4-turbo",
                    "messages": messages,
                    "temperature": kwargs.get("temperature", 0.7),
                    "max_tokens": kwargs.get("max_tokens"),
                    "stream": kwargs.get("stream", False),
                },
            )
            if resp.status_code == 429:
                raise RateLimitError("OpenAI rate limit exceeded")
            data = resp.json()
            if data.get("error"):
                raise ProviderError(data["error"]["message"])
            return data

    async def generate(self, messages: List[Dict[str, str]], **kwargs) -> Dict[str, Any]:
        await self.on_start(kwargs.get("context"))
        try:
            response = await self._call_with_timeout(self._raw_call(messages, **kwargs))
            await self.on_success(response)
            return response
        except Exception as exc:
            await self.on_failure(exc)
            raise

Anthropic and Llama‑cpp follow the same skeleton; the only differences are endpoint URLs, auth headers, and context limits (Claude 3.5 Sonnet: 100 k tokens, Llama‑cpp: 32 k). For brevity, the Anthropic version swaps `httpx` for `aiohttp` and respects the **Messages API** schema; the Llama‑cpp version invokes a local binary via `subprocess` and streams stdout.

*Internal link:* For a deeper look at abstract base classes, see my tutorial on **[Python Abstract Base Classes (ABC) for Clean Contracts]**. (link inserted where the base class is introduced.)

The Context Builder & Fallback Chain Strategy

# router.py  # python 3.12
from typing import List, Dict, Any
from .provider import ProviderInterface, PromptContext, ContextLengthError
from .openai_provider import OpenAIProvider
from .anthropic_provider import AnthropicProvider
from .llama_cpp_provider import LlamaCPPProvider

class ModelRouter:
    def __init__(self, providers: List[ProviderInterface]):
        self.providers = providers  # ordered by preference

    async def invoke(self, raw_prompt: str, **opts) -> Dict[str, Any]:
        last_error: ProviderError | None = None
        for provider in self.providers:
            ctx, msgs = await provider.build_context(raw_prompt)
            # Attach context for lifecycle hooks
            provider_kwargs = {**opts, "context": ctx, "max_tokens": ctx.max_tokens}
            try:
                return await provider.generate(msgs, **provider_kwargs)
            except ContextLengthError:
                # This provider can't handle the prompt; try next.
                continue
            except ProviderError as exc:
                last_error = exc
                # Let retry/circuit‑breaker handle it; if still failing go to next.
                continue
        raise RuntimeError("All providers failed") from last_error

The router embodies the **Fallback Chain Strategy**: try the cheapest/local model first, then move up the ladder. You can add a **circuit breaker** per provider (see the dedicated “Circuit Breaker” article) to short‑circuit providers that are repeatedly failing.

Critical Production Gotchas & How to Solve Them

Real Error Handling: Retries, Circuit Breakers, and Observability

*Error pattern #1 – Rate limiting* **Symptom:** `RateLimitError: OpenAI rate limit exceeded` floods logs. **Why:** Tenacity retries blindly, causing a spike during a genuine outage. **Fix:** Wrap the provider in a **circuit breaker** that trips after three consecutive `RateLimitError`s and opens for a cooldown period. The pattern is demonstrated in my [Circuit Breaker Go: 5 Ways to Safeguard AI APIs (2026)](https://nileshblog.tech/?p=6938) article, but the Python equivalent lives in `circuit.py`:

# circuit.py  # python 3.12
import time
from collections import defaultdict
from .provider import ProviderError, RateLimitError

class CircuitBreaker:
    def __init__(self, failure_threshold: int = 3, recovery_time: float = 30.0):
        self.failure_threshold = failure_threshold
        self.recovery_time = recovery_time
        self._failures = defaultdict(int)
        self._opened_at: dict[str, float] = {}

    def allow(self, name: str) -> bool:
        if name in self._opened_at:
            if time.time() - self._opened_at[name] > self.recovery_time:
                del self._opened_at[name]  # half‑open
Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.