When I started integrating LLMs into my side project AuraFlow AI, I made the same mistake most backend engineers make: I hardcoded a single provider.
One week in, Gemini free tier hit its rate limit at 11 PM while I was testing. Everything stopped. I had to manually swap the API key, restart the worker, and lose 20 minutes of debugging momentum.
That was the last time I let a single LLM provider be a single point of failure.
AuraFlow AI is a distributed data cleaning system I built for my portfolio. The architecture is straightforward:
POST /jobs (NestJS/Fastify/Bun)
→ BullMQ job pushed to Redis
→ Python LangGraph worker picks up job
→ Parser Agent: clean raw/malformed data using LLM
→ Validator Agent: verify output, loop back if invalid
→ HTTP callback with retry + idempotency
→ Result persisted to PostgreSQL
The LLM is at the core of both agents. If the LLM provider goes down, the entire system stops. That's the problem I needed to solve.
My first implementation looked like this:
from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(
model="gemini-3.5-flash",
google_api_key=os.getenv("GEMINI_API_KEY")
)
def parse_node(state):
result = llm.invoke(prompt)
return {"cleaned_data": result.content}
Simple. Works. Completely fragile.
The problems were obvious once I started thinking about production:
I needed a proper abstraction layer.
I wanted three things:
The result is LLMRouter
: a registry-based provider abstraction with a configurable fallback chain.
class LLMRouter:
def __init__(self):
self._chain: list[tuple[str, BaseChatModel]] = []
self._build_chain()
def _build_chain(self):
raw = os.getenv("LLM_PROVIDER_ORDER", "gemini,groq,openai,claude,custom")
order = [p.strip() for p in raw.split(",")]
for provider_name in order:
, default_model = PROVIDER_REGISTRY[provider_name]
model = os.getenv(f"{provider_name.upper()}_MODEL", default_model)
instance = (model)
if instance:
self._chain.append((provider_name, instance))
logger.info("Provider loaded: %s (model: %s)", provider_name, model)
else:
logger.info("Provider skipped (no API key): %s", provider_name)
if not self._chain:
raise RuntimeError("No LLM provider available.")
logger.info("Fallback chain: %s",
" -> ".join(name for name, _ in self._chain))
def invoke(self, prompt: str) -> str:
last_error = None
for provider_name, llm in self._chain:
try:
result = llm.invoke(prompt)
return result.content.strip()
except Exception as e:
logger.warning("Provider %s failed: %s", provider_name, e)
last_error = e
continue
raise RuntimeError(f"All providers failed. Last error: {last_error}")
Each provider has its own function that returns None
if the API key is not set:
def _load_gemini(model: str) -> Optional[BaseChatModel]:
api_key = os.getenv("GEMINI_API_KEY")
if not api_key:
return None # Skip silently — not an error
try:
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(model=model, google_api_key=api_key)
except Exception as e:
logger.warning("Gemini load failed: %s", e)
return None
The registry maps provider names to functions and default models:
PROVIDER_REGISTRY = {
"gemini": (_load_gemini, "gemini-3.6-flash"),
"openai": (_load_openai, "gpt-4o-mini"),
"claude": (_load_claude, "claude-haiku-4-5-20251001"),
"groq": (_load_groq, "openai/gpt-oss-20b"),
"azure_openai": (_load_azure_openai, "gpt-4o-mini"),
"custom": (_load_custom, "llama3.2"),
}
The most useful feature ended up being one I almost didn't build: the custom OpenAI-compatible endpoint.
def _load_custom(model: str) -> Optional[BaseChatModel]:
base_url = os.getenv("CUSTOM_LLM_BASE_URL")
if not base_url:
return None
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model,
base_url=base_url,
api_key=os.getenv("CUSTOM_LLM_API_KEY", "custom"),
)
This works with anything that implements the OpenAI chat completions spec: Ollama, OpenRouter, vLLM, or a third-party API gateway. In my case, I'm using a custom API gateway as primary provider with Gemini as fallback:
LLM_PROVIDER_ORDER=custom,gemini
CUSTOM_LLM_BASE_URL=https://my-api-gateway.example.com/v1
CUSTOM_LLM_API_KEY=my_key
CUSTOM_LLM_MODEL=claude-opus-4-8
GEMINI_MODEL=gemini-3.6-flash
Startup log confirms the chain:
Provider loaded: custom (model: claude-opus-4-8)
Provider loaded: gemini (model: gemini-3.6-flash)
Provider skipped (no API key): groq
Provider skipped (no API key): openai
Fallback chain: custom -> gemini
I didn't plan this, but it happened during testing and it validated the entire design.
I submitted a job while Docker Compose was running. The gateway crashed mid-flight (exited with code 137
— OOM killed). Here's the exact log sequence from the worker:
job_received job_id=cmsi1m2dn000001pmnnhwojpf
parse_node attempt=1
Invoking provider: custom
parse_node output={"name": "Test Retry1", ...}
validate_node attempt=1
validate_node is_valid=False reason=name must be two words with each word
capitalized and contain only letters; 'Retry1' contains a digit
graph_decision result=retry attempt=1
parse_node attempt=2
parse_node output={"name": "Test Retry", ...}
validate_node is_valid=True reason=OK
graph_decision result=valid attempts=2
job_finished job_id=cmsi1m2dn000001pmnnhwojpf is_valid=True attempts=2
callback_connection_error ... attempt=1/5
callback_retry_scheduled delay=1.0s next_attempt=2
callback_connection_error ... attempt=2/5
callback_retry_scheduled delay=2.0s next_attempt=3
callback_connection_error ... attempt=3/5
callback_retry_scheduled delay=4.0s next_attempt=4
callback_sent status_code=200 attempt=4
Three things happened simultaneously that I didn't orchestrate:
1. LangGraph retry loop worked. The validator correctly rejected Test Retry1
because Retry1
contains a digit. The parser received the rejection reason and fixed it on attempt 2. The feedback loop between validator and parser is exactly what makes this more than a simple LLM call.
2. Callback retry with exponential backoff worked. The gateway was down. The worker kept retrying with 1s → 2s → 4s delays. When the gateway came back up, attempt 4 succeeded. No data was lost.
3. The worker kept running. Because LangGraph and the callback retry are separate from the gateway's lifecycle, the worker finished its job correctly even though the service it was trying to reach was down.
This is what I was aiming for. To switch from custom + Gemini to OpenAI + Groq:
LLM_PROVIDER_ORDER=custom,gemini
LLM_PROVIDER_ORDER=openai,groq
OPENAI_API_KEY=sk-...
GROQ_API_KEY=gsk_...
To override the model for a specific provider:
GEMINI_MODEL=gemini-3.6-flash
OPENAI_MODEL=gpt-4o
GROQ_MODEL=openai/gpt-oss-20b
The router reads these at startup and builds the chain. Five providers configured, but only the ones with valid credentials get loaded.
Health check before adding to chain. Right now, _load_custom
succeeds if CUSTOM_LLM_BASE_URL
is set — even if the endpoint is unreachable. A lightweight ping at startup would catch misconfigured endpoints before the first real job comes in.
Provider-level metrics. I want to know which provider is being invoked most often and which ones are failing. Right now this lives in logs. It should be structured data I can query.
Circuit breaker per provider. If Gemini returns 5xx three times in a row, stop trying Gemini for the next 60 seconds before retrying. Right now every invoke attempt goes through the full retry before moving to the next provider in the chain.
Single provider = single point of failure. This is obvious in hindsight, but it took an 11 PM rate limit hit to make me actually fix it.
The pattern is simple: registry of s, environment-configured priority, iterate and fallback on exception. About 100 lines of Python. The complexity is low; the resilience gain is significant.
If you're building any backend system that calls an LLM, this abstraction is worth the hour it takes to implement.
AuraFlow AI source code: github.com/awaluddin-dev/auraflow-ai
I'm Awaluddin — Backend Engineer & AI Integrator based in Jakarta, currently consulting at an enterprise automotive company. Building toward a fully remote role. You can find my work at awaluddin-dev.vercel.app or connect on LinkedIn.