cd /news/large-language-models/i-built-a-multi-provider-llm-router-… · home topics large-language-models article
[ARTICLE · art-108651] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=· neutral

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.

read6 min views1 publishedAug 24, 2026

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.

── more in #large-language-models 4 stories · sorted by recency
── more on @auraflow ai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-built-a-multi-prov…] indexed:0 read:6min 2026-08-24 ·