I Built a Multi-Provider LLM Router for My AI Worker - Here's What I Learned A developer built a multi-provider LLM router for their distributed data cleaning system AuraFlow AI, which uses LangGraph agents and BullMQ. The router, called LLMRouter, maintains a configurable fallback chain across providers like Gemini, OpenAI, Claude, and Groq, automatically skipping providers without API keys and retrying on failures. The developer learned that hardcoding a single LLM provider creates a single point of failure, as experienced when Gemini's rate limit halted the system. 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: python 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. python class LLMRouter: def init self : self. chain: list tuple str, BaseChatModel = self. build chain def build chain self : Priority order from env — change without touching code raw = os.getenv "LLM PROVIDER ORDER", "gemini,groq,openai,claude,custom" order = p.strip for p in raw.split "," for provider name in order: loader, default model = PROVIDER REGISTRY provider name model = os.getenv f"{provider name.upper } MODEL", default model instance = loader 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 loader function that returns None if the API key is not set: php 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 loader 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. php 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: .env 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: Before LLM PROVIDER ORDER=custom,gemini After — no code change, just env update 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 loaders, 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 https://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.