Building a Multi-Provider LLM Client That Doesn't Silently Break — A 7-Layer Architecture for Production AI Agents
If your agent calls an LLM and gets an empty string back, does anything in your stack notice? If not, this article is the post-mortem you need to write before your users write it for you.
What's inside
1. The problem nobody talks about
Most teams wire up an LLM client the easy way:
import openai
resp = openai.chat.completions.create(model="gpt-4o", messages=[...])
return resp.choices[0].message.content
That works on day one. Then your agent ships to production and, six weeks later, you start getting support tickets that say things like "the bot just says nothing" or "sometimes it sends a one-word answer to a long question". You dig into the logs and find entries like:
200 OK | prompt_tokens=812 | completion_tokens=0 | content="" | finish_reason="stop"
200 OK | prompt_tokens=812 | completion_tokens=1 | content="." | finish_reason="stop"
200 OK | prompt_tokens=812 | completion_tokens=12 | content="I cannot help with that." | finish_reason="stop"
All three are successful responses. None of them are useful. Your client returns the empty string to the agent loop, the agent loop decides "no tool calls needed," and your user gets a blank DM. No exception. No retry. No alert. Just silent, compounding damage to your product's reputation.
This isn't a one-provider problem. It's structural. Every provider has a different failure surface: empty completions, refusals, mid-stream truncations, rate-limit headers you have to respect, sudden JSON schema drift after a model update, and the occasional 200 OK that returns literally {"choices": []}. If your client only checks status_code == 200, you're not running an LLM client — you're running a slot machine.
2. Why this is the #1 production failure mode for AI agents in 2026
Three trends converged:
- Multi-provider is now table stakes. Single-vendor lock-in is too risky for anything customer-facing. Most teams now route across 2-4 providers per request (OpenAI, Anthropic, a cheap local model, an OSS gateway).
- Providers are pushing more load to rate-limit edges. Token-per-minute limits tighten every quarter, and
Retry-Afterheaders are now frequently load-bearing. - Agents are longer-running. A 30-step agent loop hits a provider 30+ times. The probability that at least one of those returns empty or refuses approaches 1.
The teams that survive this are the ones who stopped treating the provider as a trusted function and started treating it as an unreliable network peer. The teams that don't survive keep adding try/except around things that don't throw.
3. The 7-layer architecture
Here's the stack we run in production after a year of post-mortems. Each layer has one job, fails loud, and feeds the next.
ChatRequest into the specific auth, header, body, and streaming format each provider expects. Then translate the response back. This is where 80% of the bugs hide, because every provider's "API" is its own snowflake.
NormalizedResponse shape. Strips provider-specific weirdness (Anthropic's stop_reason, OpenAI's finish_reason enum, Gemini's nested candidates).This is the layer that catches the bugs the others can't even see.
Retry-After headers. Never retry a refusal — that's a permanent failure, not a transient one.ChatRequest to the next provider in the chain — cheapest first, most-capable last. Order matters and so does the policy: see section 5 for why "fallback for safety" burns money.
Now let's walk through each layer with code.
Layer 1: Provider adapters
The cardinal sin is letting provider-specific shapes leak into your agent loop. Define a canonical request and response, then write adapters for each provider:
from dataclasses import dataclass, field
from typing import Literal
@dataclass
class ChatMessage:
role: Literal["system", "user", "assistant", "tool"]
content: str
name: str | None = None
@dataclass
class ChatRequest:
model_hint: str | None = None # optional override
messages: list[ChatMessage] = field(default_factory=list)
temperature: float = 0.7
max_tokens: int = 1024
tools: list[dict] | None = None
stream: bool = False
# Metadata that follows the request through every layer
session_id: str = ""
user_id: str = ""
request_id: str = ""
class ProviderAdapter:
name = "abstract"
def complete(self, req: ChatRequest) -> "RawResponse": ...
def stream(self, req: ChatRequest) -> "Iterator[RawChunk]": ...
The adapter owns auth, base URL, header construction, request body shape, SSE parsing, and rate-limit header interpretation. Everything provider-specific lives here. When a provider ships a breaking change, exactly one file changes.
Layer 2: Response normalizer
Each provider returns success in its own dialect. Normalize once, downstream code stays clean:
@dataclass
class NormalizedResponse:
text: str
finish_reason: str # "stop" | "length" | "refusal" | "tool_call" | "empty"
tool_calls: list[dict] = field(default_factory=list)
prompt_tokens: int = 0
completion_tokens: int = 0
provider: str = ""
model: str = ""
latency_ms: int = 0
def normalize(raw: "RawResponse", provider: str) -> NormalizedResponse:
if provider == "openai":
return NormalizedResponse(
text=raw.choices[0].message.content or "",
finish_reason=raw.choices[0].finish_reason,
prompt_tokens=raw.usage.prompt_tokens,
completion_tokens=raw.usage.completion_tokens,
provider="openai", model=raw.model,
)
if provider == "anthropic":
return NormalizedResponse(
text="".join(b.text for b in raw.content if b.type == "text"),
finish_reason=raw.stop_reason, # "end_turn" -> "stop" mapped below
prompt_tokens=raw.usage.input_tokens,
completion_tokens=raw.usage.output_tokens,
provider="anthropic", model=raw.model,
)
raise NotImplementedError(provider)
The key discipline: finish_reason becomes a small closed enum. If a provider sends a value you don't recognize, you fail loud at this layer — never silently coerce it to "stop".
Layer 3: Empty/refusal classifier (the killer feature)
This is the layer you almost never see in tutorials and the one that prevents 90% of silent-failure tickets. Add it after normalization:
REFUSAL_PATTERNS = [
r"^i (?:can't|cannot|won't|will not|am unable to|am not able to)\b",
r"^as an? (?:ai|language model|assistant)\b",
r"^i'm sorry,? but\b",
r"^i don't (?:feel|have|do)\b",
r"^i can'?t help (?:with|you)\b",
]
def classify_quality(resp: NormalizedResponse) -> str:
text = (resp.text or "").strip()
if resp.finish_reason in ("length",) and not text:
return "truncated_empty"
if not text:
return "empty"
if len(text) < 4 and text in {".", "?", "!", "...", "-"}:
return "empty"
if resp.completion_tokens < 4:
return "suspiciously_short"
low = text.lower()
for pat in REFUSAL_PATTERNS:
if re.match(pat, low):
return "refusal"
return "ok"
Anything that returns "empty", "refusal", "truncated_empty", or "suspiciously_short" is treated as a failure by the retry policy. This is the bug that 3-hour debugging story is about — see section 4.
Why this matters: without this layer, every other layer downstream assumes "200 OK means usable content." That assumption is wrong. The classifier is the wall between success and actually useful.
Layer 4: Retry policy
Two rules that sound obvious until you read real production code:
- Don't retry refusals. They're policy decisions, not transient failures.
- Respect
Retry-Afterto the second. It's not a hint.
def should_retry(quality: str, attempt: int, retry_budget: int) -> bool:
if attempt >= retry_budget:
return False
if quality in ("refusal",):
return False # permanent
return quality in ("empty", "truncated_empty", "suspiciously_short")
def backoff(attempt: int, retry_after: float | None) -> float:
if retry_after is not None:
return retry_after + random.uniform(0, 0.5)
return min(30.0, (2 ** attempt)) + random.uniform(0, 1.0)
Layer 5: Fallback chain
Order the chain so the cheapest, fastest, highest-capacity provider is first and the most-capable-but-expensive provider is last. Do not put the most-capable first "for safety" — see section 5.
FALLBACK_CHAIN = [
{"provider": "deepseek", "model": "deepseek-chat", "retry_budget": 2},
{"provider": "openai", "model": "gpt-4o-mini", "retry_budget": 2},
{"provider": "anthropic", "model": "claude-haiku-4-5", "retry_budget": 1},
{"provider": "anthropic", "model": "claude-sonnet-4-5","retry_budget": 1},
]
def call_with_fallback(req: ChatRequest, chain=FALLBACK_CHAIN) -> NormalizedResponse:
last_err = None
for tier in chain:
adapter = get_adapter(tier["provider"])
req.model_hint = tier["model"]
for attempt in range(tier["retry_budget"]):
try:
raw = adapter.complete(req)
resp = normalize(raw, tier["provider"])
quality = classify_quality(resp)
observability.log(req, resp, quality, attempt, tier)
if quality == "ok":
return resp
if not should_retry(quality, attempt, tier["retry_budget"]):
break
time.sleep(backoff(attempt, raw.retry_after))
except RateLimitError as e:
time.sleep(backoff(attempt, e.retry_after))
last_err = e
except TransientError as e:
last_err = e
# exhausted this tier — fall through to next
raise AllProvidersFailed(last_err)
Layer 6: Cost tracker
class CostTracker:
def __init__(self, daily_cap_usd: float = 50.0, per_user_cap_usd: float = 2.0):
self.daily_cap = daily_cap_usd
self.user_cap = per_user_cap_usd
self.spent_today = 0.0
self.per_user: dict[str, float] = {}
def record(self, user_id: str, cost_usd: float) -> None:
self.spent_today += cost_usd
self.per_user[user_id] = self.per_user.get(user_id, 0.0) + cost_usd
if self.spent_today > self.daily_cap:
raise HardAbort("daily_cap_exceeded")
if self.per_user[user_id] > self.user_cap:
raise HardAbort("user_cap_exceeded")
Hard-abort is a feature, not a failure mode. A $500 surprise on a Monday morning will end a startup faster than any outage.
Layer 7: Observability
def log(req: ChatRequest, resp: NormalizedResponse,
quality: str, attempt: int, tier: dict) -> None:
db.execute("""
INSERT INTO llm_calls (
ts, request_id, session_id, user_id,
provider, model, attempt, quality,
finish_reason, prompt_tokens, completion_tokens,
cost_usd, latency_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
time.time(), req.request_id, req.session_id, req.user_id,
tier["provider"], resp.model, attempt, quality,
resp.finish_reason, resp.prompt_tokens, resp.completion_tokens,
cost_of(tier, resp), resp.latency_ms,
))
Now when support asks "why was this user's session silent yesterday at 4pm?" the answer is one SQL query away.
4. The 3-line bug fix that took me 3 hours to find
This is the bug that made me write the empty/refusal classifier.
An agent was failing silently. Logs showed clean 200 OK responses. Token counts looked fine. The downstream tool calls were just... empty. The user reported "the bot stopped responding around noon."
I spent the first hour convinced it was a network issue. Then a serialization bug. Then a model regression. None of those explained why everything in the trace looked healthy.
The actual cause was a 3-line block in the OpenAI adapter:
# WRONG - what shipped
api_key = os.getenv("MINIMAX_API_KEY") # but the env var on prod was
# named MINIMAX_SUBSCRIPTION_KEY
if not api_key:
api_key = os.getenv("OPENAI_API_KEY") # silent fallback to OpenAI's key
client = openai.OpenAI(api_key=api_key)
Three problems compounded:
- The wrong env var name meant the primary provider call failed at the auth step, not at request time.
- The silent fallback to
OPENAI_API_KEYsucceeded — with an account that had no quota left. - OpenAI's quota-exhausted response was a 200 OK with
completion_tokens=0andfinish_reason="stop".
None of that looked broken to the retry layer, because nothing was broken at the HTTP layer. The empty/refusal classifier — added that week — caught it in classify_quality: empty text + zero completion tokens = "empty", which triggered fallback to the next provider in the chain, which then succeeded.
The fix:
# RIGHT
api_key = os.getenv("MINIMAX_API_KEY")
if not api_key:
raise ConfigError("MINIMAX_API_KEY missing — refusing silent fallback")
client = openai.OpenAI(api_key=api_key)
Three lines. Three hours. The lesson isn't "check your env vars" — it's "never let silent fallbacks hide a misconfiguration behind a 200 OK." The classifier is what made this debuggable.
5. Why "fallback for safety" wastes money
A surprisingly common anti-pattern: put the most-capable model first in the fallback chain "in case the cheap one fails." Then mark every tier as retry_budget=3. Then disable the classifier.
What you end up with: every request first hits the expensive model, retries 3 times on anything weird, and only falls through on hard failure. Your bill triples. Your latency triples. And the quality is identical to running cheap-first, because the cheap provider was working 99% of the time.
Real numbers from a week of production logs we audited:
- Primary provider succeeded without retry: 97.4%
- Primary succeeded after 1-2 retries: 1.8%
- Required fallback to provider 2: 0.6%
- Required fallback to provider 3: 0.18%
- Required fallback to provider 4 (the expensive one): 0.04%
Routing everything through the expensive model "for safety" cost 14× more for a 0.04% quality gain. The classifier + cheap-first chain got the same quality at a third of the bill.
6. Streaming complications: buffer-then-score vs per-chunk scoring
Streaming breaks the classifier in interesting ways. Two patterns:
Buffer-then-score
Collect every chunk, concatenate, run the classifier once at the end. Simple, correct, but you can't show partial output to the user until the whole stream is done — which defeats streaming.
Per-chunk scoring
Run a lightweight classifier on each chunk's accumulated prefix. Cheap heuristic: if the first 80 characters match a refusal pattern, abort the stream and trigger fallback. Risk: a refusal can appear late in a stream (provider starts answering, then adds "actually, I can't help with that"). Mid-stream refusal detection is still an open problem.
Our default is per-chunk with a final pass on the complete buffer. Trade latency for correctness — and always assume the final buffer is the source of truth for billing/quality.
7. Provider outage patterns: always Friday 5pm — have a static fallback
Multi-provider fallback chains handle the unexpected failure. They do not handle the predictable one: every primary provider has a soft outage on Friday between 5pm and 7pm local time. This is empirically true for at least four major providers we've tracked. It's not malice — it's the weekly deploy window, the weekend traffic ramp, and the on-call engineer's last deploy before signing off.
If your entire fallback chain depends on dynamic provider routing, you will be down at the worst possible time. Have a static fallback — a smaller, locally-hostable model (Llama-3-70B-Instruct, Qwen-72B, Mistral-Large) that runs offline when everything else is on fire. Quality will drop. Latency will rise. But your agent will keep moving.
Wire it as tier 0 in the chain, gated behind a health-check flag you flip manually or via circuit breaker:
FALLBACK_CHAIN = [
{"provider": "local_llama", "model": "llama-3-70b-instruct",
"retry_budget": 1, "static_fallback": True},
{"provider": "deepseek", "model": "deepseek-chat", "retry_budget": 2},
{"provider": "openai", "model": "gpt-4o-mini", "retry_budget": 2},
{"provider": "anthropic", "model": "claude-sonnet-4-5","retry_budget": 1},
]
The static tier only fires when the circuit breaker is open for every upstream provider. In normal operation it sits there, costing nothing, ready for Friday at 5:01pm.
Putting it all together
If I had to compress this entire post into a checklist:
- Define a canonical
ChatRequestandNormalizedResponse. Don't leak provider shapes. - Add an empty/refusal classifier before your retry policy. This is the layer that pays for itself in the first week.
- Respect
Retry-After. Don't retry refusals. Don't silently fall back through misconfiguration. - Route cheap-first, capable-last. Most requests succeed at tier 1.
- Track cost per session and per user. Hard-abort on cap.
HardAbortis a feature. - Log every call to a queryable table. If you can't answer "what happened Tuesday at 4pm" in 30 seconds, your observability is broken.
- Have a static fallback. Friday at 5pm is a feature of the industry, not a coincidence.
The 7 layers aren't novel. What's novel is treating them as layers — each with one job, each testable in isolation, each fixable without touching the others. That's the difference between an LLM client and a production LLM client.
We engineer these layers on top of battle-tested production foundations and stress-test them across millions of operations before they ever see a paying customer. The full reliability stack — adapters, classifier, cost tracker, observability dashboard, static fallback tier — is wired into the agent toolkit we ship at talonforgehq.github.io/atlas-store. If you're building an agent that has to stay alive at 5pm on a Friday, the architecture is worth copying.