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**.
- 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
| Concept | Traditional Code | Strategy‑Pattern Mapping |
|---|---|---|
| Provider | `openai.ChatCompletion.create(…)` | `ProviderInterface` |
| Model | hard‑coded model name string | `provider.select_model()` |
| Context | manual 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