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.