Pattern-012: LLM Adapter Pattern

Status

Proven

Context

Applications using Large Language Models face vendor lock-in and switching costs when tightly coupled to specific LLM providers. Different providers have varying APIs, capabilities, and pricing models, making it difficult to switch or compare providers. The LLM Adapter Pattern addresses:

Pattern Description

The LLM Adapter Pattern creates a common interface for all LLM providers, allowing applications to switch between providers without code changes and enabling capabilities like fallback, cost optimization, and performance comparison.

Core concept:

Implementation

Core Adapter Interface

from abc import ABC, abstractmethod
from typing import List, Tuple, Optional, Dict, Any

class LLMAdapter(ABC):
    """Common interface for all LLM providers"""

    @abstractmethod
    async def complete(self, prompt: str, **kwargs) -> str:
        """Generate text completion"""
        pass

    @abstractmethod
    async def classify(self, text: str, categories: List[str]) -> Tuple[str, float]:
        """Classify text into categories with confidence"""
        pass

    @abstractmethod
    async def embed(self, text: str) -> List[float]:
        """Generate embeddings for text"""
        pass

    @abstractmethod
    def supports_streaming(self) -> bool:
        """Check if provider supports streaming responses"""
        pass

    @abstractmethod
    async def get_model_info(self) -> Dict[str, Any]:
        """Get model capabilities and limits"""
        pass

Provider-Specific Implementations

class ClaudeAdapter(LLMAdapter):
    """Claude-specific implementation"""

    def __init__(self, api_key: str, model: str = "claude-3-opus"):
        self.client = Anthropic(api_key=api_key)
        self.model = model

    async def complete(self, prompt: str, **kwargs) -> str:
        response = await self.client.messages.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            max_tokens=kwargs.get("max_tokens", 1000),
            temperature=kwargs.get("temperature", 0.7)
        )
        return response.content[0].text

    async def classify(self, text: str, categories: List[str]) -> Tuple[str, float]:
        prompt = f"""
        Classify this text into one of these categories: {', '.join(categories)}
        Text: {text}

        Return format: category_name:confidence_score
        """
        response = await self.complete(prompt)
        parts = response.split(':')
        return parts[0].strip(), float(parts[1].strip())

    async def embed(self, text: str) -> List[float]:
        # Claude doesn't provide embeddings, use alternative or raise NotImplementedError
        raise NotImplementedError("Claude doesn't support embeddings")

    def supports_streaming(self) -> bool:
        return True

    async def get_model_info(self) -> Dict[str, Any]:
        return {
            "provider": "anthropic",
            "model": self.model,
            "max_tokens": 100000,
            "supports_streaming": True,
            "supports_embeddings": False
        }

class OpenAIAdapter(LLMAdapter):
    """OpenAI-specific implementation"""

    def __init__(self, api_key: str, model: str = "gpt-4"):
        self.client = OpenAI(api_key=api_key)
        self.model = model

    async def complete(self, prompt: str, **kwargs) -> str:
        response = await self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            max_tokens=kwargs.get("max_tokens", 1000),
            temperature=kwargs.get("temperature", 0.7)
        )
        return response.choices[0].message.content

    async def classify(self, text: str, categories: List[str]) -> Tuple[str, float]:
        # Similar implementation with OpenAI format
        pass

    async def embed(self, text: str) -> List[float]:
        response = await self.client.embeddings.create(
            model="text-embedding-ada-002",
            input=text
        )
        return response.data[0].embedding

    def supports_streaming(self) -> bool:
        return True

    async def get_model_info(self) -> Dict[str, Any]:
        return {
            "provider": "openai",
            "model": self.model,
            "max_tokens": 8192,
            "supports_streaming": True,
            "supports_embeddings": True
        }

Adapter Factory

class LLMFactory:
    """Creates appropriate adapter based on configuration"""

    _adapters: Dict[str, type] = {
        "claude": ClaudeAdapter,
        "openai": OpenAIAdapter,
        "local": LocalLLMAdapter,
        "azure": AzureOpenAIAdapter
    }

    @classmethod
    def create(cls, provider: str, **kwargs) -> LLMAdapter:
        """Create adapter for specified provider"""
        if provider not in cls._adapters:
            raise ValueError(f"Unknown provider: {provider}. Available: {list(cls._adapters.keys())}")

        adapter_class = cls._adapters[provider]
        return adapter_class(**kwargs)

    @classmethod
    def register_adapter(cls, provider: str, adapter_class: type):
        """Register custom adapter"""
        cls._adapters[provider] = adapter_class

    @classmethod
    def list_providers(cls) -> List[str]:
        """List available providers"""
        return list(cls._adapters.keys())

Multi-Provider Manager

class LLMManager:
    """Manages multiple providers with fallback and load balancing"""

    def __init__(self, config: Dict[str, Dict]):
        self.adapters = {}
        self.primary_provider = None
        self.fallback_providers = []

        for provider, provider_config in config.items():
            adapter = LLMFactory.create(provider, **provider_config)
            self.adapters[provider] = adapter

            if provider_config.get("primary", False):
                self.primary_provider = provider
            if provider_config.get("fallback", False):
                self.fallback_providers.append(provider)

    async def complete(self, prompt: str, **kwargs) -> str:
        """Try primary provider, fallback on failure"""
        providers_to_try = [self.primary_provider] + self.fallback_providers

        for provider in providers_to_try:
            try:
                adapter = self.adapters[provider]
                return await adapter.complete(prompt, **kwargs)
            except Exception as e:
                logger.warning(f"Provider {provider} failed: {e}")
                continue

        raise LLMProviderError("All providers failed")

    async def compare_providers(self, prompt: str) -> Dict[str, str]:
        """Compare responses from all providers"""
        results = {}
        for provider, adapter in self.adapters.items():
            try:
                response = await adapter.complete(prompt)
                results[provider] = response
            except Exception as e:
                results[provider] = f"Error: {e}"
        return results

Usage Guidelines

Interface Design

Configuration Management

Error Handling

Benefits

Trade-offs

Anti-patterns to Avoid

References

Migration Notes

Consolidated from: