Module 8: Unified AI Client — Final integrating project
Architecture and design
Before typing a single line of code, we're going to design the interface. This isn't perfectionism — it's the difference between implementing once vs. refactoring three times. Every decision you make here saves you work and gives you consistency across the 5 adapters you're going to write.
By the end you'll be able to:
- Decide the contract of
UnifiedClientandBaseAdapterwith clear types - Design the configuration flow (in code vs YAML vs env vars)
- Map the Adapter, Factory and Strategy patterns to concrete pieces
- Document design decisions with reasons (not just what, but why)
The decisions we're going to make
Five big decisions, in order of impact:
- What methods does the public interface expose? (minimal + extensible)
- What type of inputs/outputs? (simple string vs rich structures)
- How is it configured? (constructor params vs config object vs YAML)
- How are errors handled? (typed exceptions vs Result wrapper)
- How are extensions injected (fallback, metrics, retry) without polluting the interface?
Let's go one by one.
Decision 1 — The public interface
Option A — Minimal:
class UnifiedClient:
def __init__(self, provider: str, **kwargs): ...
def chat(self, prompt: str) -> str: ...
Option B — Rich:
class UnifiedClient:
def __init__(self, provider: str, **kwargs): ...
def chat(self, prompt: str, **opts) -> ChatResponse: ...
def stream_chat(self, prompt: str, **opts) -> Iterator[str]: ...
def chat_with_history(self, messages: list[Message], **opts) -> ChatResponse: ...
def embed(self, texts: list[str]) -> list[Embedding]: ...
Decision: something between the two. We start with:
class UnifiedClient:
def __init__(self, primary: str, fallback: list[str] = [], **opts): ...
def chat(
self,
prompt: str,
*,
max_tokens: int = 256,
temperature: float = 0.7,
system: str | None = None,
) -> ChatResponse: ...
def chat_with_messages(
self,
messages: list[Message],
**opts
) -> ChatResponse: ...
def get_metrics(self) -> Metrics: ...
Why:
chat()for the simple case (90% of usage).chat_with_messages()for multi-turn (when your app manages history).- No streaming, embeddings, function calling (explicit M08 scope).
- Small public methods; extensions (fallback, metrics) injected, not new methods.
Decision 2 — Inputs and outputs
Simple strings vs structures:
# Option A — strings: simple but loses info
def chat(self, prompt: str) -> str: ...
# Option B — Pydantic: structured but more code
def chat(self, prompt: str) -> ChatResponse:
...
class ChatResponse(BaseModel):
text: str
model: str
provider: str
tokens_input: int
tokens_output: int
duration_ms: int
cost_usd: float | None
raw_response: dict # access to the original response if you need it
Decision: Option B (structured).
Why:
- You need
tokensandcostfor tracking (capsule 06) - You need
providerto know who answered (important with fallback) - You need
raw_responsefor advanced cases without polluting the base API - The extra cost of Pydantic is trivial; the value of structured info is high
Decision 3 — Configuration
Option A — Constructor params:
client = UnifiedClient(
primary="openai",
fallback=["openrouter", "ollama"],
openai_api_key="sk-...",
openrouter_api_key="sk-or-...",
)
Option B — Config object:
config = ClientConfig(
primary=ProviderConfig(name="openai", model="gpt-4o-mini"),
fallback=[
ProviderConfig(name="openrouter", model="mistralai/mistral-7b-instruct"),
ProviderConfig(name="ollama", model="mistral"),
],
)
client = UnifiedClient(config)
Option C — External YAML/JSON:
client = UnifiedClient.from_yaml("clients.yaml")
Decision: A for the API in code, C for production. We implement both.
Why:
- A is ergonomic for development and notebooks
- C is the right thing for production (config in a file, not hardcoded)
- B introduces ceremony that isn't justified in the simple case
- API keys always come from env vars or a secret manager, not constructor params
YAML structure:
# clients.yaml
primary: openai-gpt4o-mini
fallback:
- openrouter-mistral
- ollama-mistral
providers:
openai-gpt4o-mini:
type: openai
model: gpt-4o-mini
api_key_env: OPENAI_API_KEY
base_url: https://api.openai.com/v1
openrouter-mistral:
type: openai_compatible
model: mistralai/mistral-7b-instruct
api_key_env: OPENROUTER_API_KEY
base_url: https://openrouter.ai/api/v1
ollama-mistral:
type: openai_compatible
model: mistral
api_key_env: OLLAMA_API_KEY # placeholder; Ollama ignores it
base_url: http://localhost:11434/v1
modal-mistral:
type: modal_custom
base_url_env: MODAL_BASE_URL
api_token_env: MODAL_API_TOKEN
Decision 4 — Error handling
Option A — Typed exceptions:
class UnifiedClientError(Exception): ...
class ProviderError(UnifiedClientError): ...
class AllProvidersFailedError(UnifiedClientError): ...
class RateLimitError(ProviderError): ...
class AuthError(ProviderError): ...
class TimeoutError(ProviderError): ...
Option B — Result wrapper:
@dataclass
class Result:
success: bool
response: ChatResponse | None
error: str | None
Decision: Option A (typed exceptions).
Why:
- Idiomatic Python uses exceptions
- Specific types allow
try/except RateLimitErrorfor selective handling - Result wrappers are common in Rust/Go, not in Python — they end up feeling foreign
- Stack traces help you debug
Decision 5 — Injecting extensions
We need to add fallback, metrics, retry — but without making the class large and messy. Two options:
Option A — Decorators / mixins:
client = with_metrics(with_fallback(BaseUnifiedClient(primary="openai")))
Option B — Declared composition:
client = UnifiedClient(
primary="openai",
fallback=["openrouter"],
metrics_enabled=True,
retry_on=[RateLimitError, TimeoutError],
)
Decision: Option B (declared composition).
Why:
- Option A is elegant but less discoverable
- Option B works as a "config object by default"; explicit
- The complexity goes inside
UnifiedClient, not in the user API
The resulting class diagram
┌─────────────────────────────────────┐
│ UnifiedClient │
│ - primary: BaseAdapter │
│ - fallback: list[BaseAdapter] │
│ - metrics: MetricsCollector │
│ + chat(prompt, **opts) -> Response │
│ + chat_with_messages(...) -> Resp │
│ + get_metrics() -> Metrics │
└────────────────┬────────────────────┘
│ delegates to
▼
┌─────────────────────────────────────┐
│ BaseAdapter (ABC) │
│ + chat(...) -> Response (abstract) │
│ + name: str │
└────────────────┬────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│OpenAIAdapter │ │OllamaAdapter │ │ModalAdapter │
│ │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
OpenAI API Ollama API Modal endpoint
┌──────────────────────────┐
│ ProviderFactory │
│ + create(config) -> │
│ BaseAdapter │
└──────────────────────────┘
ProviderFactory maps config → an instance of the correct adapter.
┌──────────────────────────┐
│ MetricsCollector │
│ + record(provider, ...) │
│ + summary() -> Metrics │
└──────────────────────────┘
MetricsCollector is a collaborator that UnifiedClient optionally uses (capsule 06).
The data models (Pydantic)
# unified_ai_client/models.py
from pydantic import BaseModel, Field
from typing import Literal
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
class ChatResponse(BaseModel):
text: str
model: str
provider: str
tokens_input: int = 0
tokens_output: int = 0
duration_ms: int = 0
cost_usd: float | None = None
raw_response: dict | None = None
class ProviderConfig(BaseModel):
name: str # unique identifier of this config (e.g. "openai-gpt4o-mini")
type: Literal["openai", "openai_compatible", "modal_custom"]
model: str | None = None
base_url: str | None = None
api_key_env: str | None = None
base_url_env: str | None = None
api_token_env: str | None = None
# Optional pricing for metrics:
price_input_per_1m: float | None = None
price_output_per_1m: float | None = None
class ClientConfig(BaseModel):
primary: str
fallback: list[str] = Field(default_factory=list)
providers: dict[str, ProviderConfig]
metrics_enabled: bool = True
The BaseAdapter contract
# unified_ai_client/adapters/base.py
from abc import ABC, abstractmethod
from ..models import Message, ChatResponse, ProviderConfig
class BaseAdapter(ABC):
def __init__(self, config: ProviderConfig):
self.config = config
self.name = config.name
@abstractmethod
def chat(
self,
messages: list[Message],
max_tokens: int = 256,
temperature: float = 0.7,
) -> ChatResponse:
"""Generates a response from messages. Per-provider implementation."""
...
Rules that all adapters must follow:
- They accept
list[Message], notprompt: str(it'sUnifiedClient's responsibility to convert a simple prompt to[Message(role="user", content=prompt)]) - They return a complete
ChatResponse(text + tokens + duration + provider name) - They raise typed exceptions (
RateLimitError,AuthError, etc.) on error - They measure their own
duration_msinternally (withtime.perf_counter())
Project file structure
unified_ai_client/
├── __init__.py
├── client.py # UnifiedClient
├── factory.py # ProviderFactory
├── exceptions.py # typed exceptions
├── models.py # ChatResponse, Message, configs
├── metrics.py # MetricsCollector
├── adapters/
│ ├── __init__.py
│ ├── base.py # BaseAdapter
│ ├── openai_adapter.py
│ ├── ollama_adapter.py
│ └── modal_adapter.py
└── tests/
├── test_unified_client.py
├── test_adapters.py
├── test_fallback.py
└── test_metrics.py
pyproject.toml # package metadata
README.md # docs and examples
clients.yaml.example # config example
Common traps in the design
Trap 1 — "Designing too completely before implementing." Many people design 5 features and only implement 1. Build an MVP that works (capsule 03) before continuing to add layers.
Trap 2 — "Each feature in its own subclass."
UnifiedClientWithFallback, UnifiedClientWithMetrics, UnifiedClientWithRetryAndFallback → combinatorial explosion. That's why we decided on composition with flags instead of a subclass hierarchy.
Trap 3 — "Support for all the features of all the providers."
If OpenAI has 47 parameters and Anthropic has 38, don't expose 85 parameters in chat(). Expose the common ones (max_tokens, temperature, system). The exclusive stuff goes in provider_specific_options or is avoided.
Trap 4 — "Types without Pydantic or dataclasses." "My return is a dict with random keys" → maintenance hell. Use Pydantic from day 1.
Trap 5 — "API key in code."
OpenAIAdapter(api_key="sk-real-key"). Never. Always via env var (which YAML references with api_key_env).
Design exercise
Before implementing (capsule 03), answer for your own project:
- Are you going to support streaming? If so, do you add
stream_chat()or unify it with astream=Trueparameter? - Your product receives prompts in multiple languages. Do your Pydantic models handle that or assume ASCII?
- Should your fallback be automatic or opt-in per request (
chat(prompt, use_fallback=False))? - Some providers charge for a failed request. Does your metrics tracking include errors? Does it charge for errors too?
There are no single answers — only explicit decisions. Write down yours.
Defaults we're going to use in the following capsules
- No streaming (explicit scope). You add it later yourself if you need it.
- UTF-8 by default in Pydantic strings (handled automatically).
- Automatic fallback. A
chat(prompt)call tries primary → first fallback → second fallback → fails. - Errors are counted in metrics but not charged (some providers do charge; adjust if it applies).
Summary
You decided:
- ✅ Public interface:
chat()+chat_with_messages()+get_metrics() - ✅ Inputs/outputs: Pydantic models (
Message,ChatResponse) - ✅ Configuration: constructor params + YAML for production
- ✅ Error handling: typed exceptions (
RateLimitError,AuthError, etc.) - ✅ Declared composition (fallback, metrics as flags) vs subclass hierarchy
Checkpoint: if you have the 5 decisions above clear and can draw the class diagram without looking, you're ready to implement.
Next capsule
03 — Base implementation. We move from design to code. You build the skeleton of the package + the first adapter (OpenAI) + the minimum viable UnifiedClient. By the end of the capsule you'll have something importable and executable, even if only with one provider.
Resources
- Refactoring.guru — Adapter, Factory, Strategy patterns — canonical definitions with Python examples.
- Pydantic v2 docs — models for your dataclasses.
- LiteLLM source code — open source implementation; study its
Routerfor fallback. - Python typing —
Protocolvs ABC — alternative to ABC with duck typing. - Architectural Decision Records template — to document your decisions.