ADR-004: Single LLM Client for All Providers#
Status#
Accepted
Context#
The backend must support two LLM providers: OpenAI (direct) and Azure OpenAI.
Both use the openai Python SDK but with different client classes
(AsyncOpenAI vs AsyncAzureOpenAI) and different initialization parameters
(API key + base URL vs API key + Azure endpoint + API version).
The architect proposed two separate adapter classes: OpenAILLMAdapter and
AzureOpenAILLMAdapter, each implementing LLMPort.
Decision#
Use a single LLMClient class that accepts a pre-configured async OpenAI
client (AsyncOpenAI or AsyncAzureOpenAI) as a constructor argument.
Provider selection happens at startup in the composition root (api/app.py),
not inside the client.
Options Considered#
Option A: Two separate adapter classes (rejected)#
Pro: Each adapter is self-contained and can be tested independently. Clear separation of provider-specific concerns.
Con: The
complete()method body is identical in both classes — sameclient.chat.completions.create(...)call, same exception mapping, samestore=Falseanduser=tenant_idenforcement. The only difference is constructor parameters. Maintaining two classes means two places to update when the call contract changes.
Option B: Single class with injected client (chosen)#
Pro: One class, one
complete()method, one place to enforcestore=False. TheopenaiSDK guarantees thatAsyncOpenAIandAsyncAzureOpenAIhave the samechat.completions.create()interface. Provider selection is a factory concern, not a client concern.Con: If the two providers diverge in their SDK interface (e.g., Azure adds a required parameter), the single class must handle the difference.
Mitigation: The
openaiSDK maintainers have committed to interface parity between the two client classes. If they diverge, splitting the class at that point is a trivial refactor.
Option C: Function instead of class (not chosen)#
A plain async def complete(client, ...) function would work but makes it
harder to carry configuration (model name) without partial application or
closures. A class with __init__ is clearer.
Consequences#
services/llm_client.pycontains oneLLMClientclass.api/app.pycontains a factory function that readsLLMSettingsand constructs eitherAsyncOpenAI(api_key=..., ...)orAsyncAzureOpenAI(api_key=..., azure_endpoint=..., api_version=...), then passes it toLLMClient(client=..., model=...).Tests mock the injected client object, not the
openaimodule. This makes tests provider-agnostic.Adding a third provider (e.g., Anthropic, local model) requires a new adapter class that implements
LLMPort— the single-client pattern does not extend to non-OpenAI-SDK providers. At that point, extract a second class.
Participants#
Devil’s advocate (proposed collapsing two classes into one)
Architect (accepted — implementation bodies are identical)