Why an adapter
You want to swap local and cloud models without rewriting the app. The pattern: define a narrow interface every provider implements, and one universal chunk type every provider emits. Routes, store, and frontend talk to the interface; provider-specific code lives behind it.
The minimum interface
stream(messages, tools, **opts)— the only method that matters. Yields a sequence of universal chunks.health_check()— fast yes/no for fallback routing.list_models()— for UI and discovery.
The universal chunk
One StreamChunk shape covers everything you need to yield: text deltas, thinking deltas, tool calls, the final done flag with timing. Each adapter translates its provider's wire shape into this universal chunk.
Don't over-abstract
The adapter is narrow on purpose. Routes know about stream(), not about NDJSON vs SSE. Adapters know about NDJSON vs SSE, not about FastAPI routes. One thing at the boundary, and that thing is the chunk yield protocol.
And this is polymorphism actually doing a day's work. One interface, many vessels behind it, and the caller never learns which one answered. It's the thing OOP textbooks explain with shape classes — except here Claude and Ollama stand behind the same stream() and prove it. You don't get it from memorizing an inheritance diagram; you feel it the night the cloud goes down and the answers keep coming.