The lock-in problem
Every LLMLLMLarge language model, the kind of model behind ChatGPT and Claude. provider has a different API. Different authentication, different request schemas, different response formats, different streaming protocols, different error codes. If you build your application directly against one provider's SDK, you're one API change, one pricing shift, or one outage away from a significant refactor.
The adapter pattern solves this — but implementing it for LLMs has specific challenges worth documenting.
The interface contract
Start by defining what your application actually needs from an LLM provider, independent of any specific API:
class LLMClient(Protocol):
async def complete(
self,
messages: list[Message],
max_tokens: int,
temperature: float,
) -> CompletionResponse: ...
async def stream(
self,
messages: list[Message],
max_tokens: int,
) -> AsyncGenerator[str, None]: ...Everything provider-specific lives inside concrete adapter classes that implement this interface. Your application never sees an Anthropic client or an OpenAI client — only an LLMClient.
The streaming problem
Streaming is where adapter implementations get genuinely difficult. Anthropic, OpenAI, Groq, and Ollama all have different SSE or chunked response formats, different event types, and different ways of signalling stream completion.
The cleanest solution I found: each adapter's stream implementation is an async generator that normalises provider-specific events into a uniform string stream. The adapter handles all the provider-specific parsing internally and yields only the text delta to the caller. The application code is identical regardless of which provider is underneath.
Error normalisation
Providers also have different error taxonomies. Rate limit errors, context length errors, and content filter errors all need to map to your application's error types — not bubble up as provider-specific exceptions that your application logic has to handle case by case.
Build an error normalisation layer inside each adapter. Define your own exception hierarchy (RateLimitError, ContextLengthError, ContentFilterError) and translate provider exceptions to your types at the adapter boundary.
The result
When we built Document Intelligence MVP with this architecture, switching between Anthropic, OpenAI, Groq, Gemini, and Ollama was a single config change. No application logic changed. The adapter pattern paid for the upfront design cost in the first week.