# I Built a Multi-Provider LLM Router for My AI Worker - Here's What I Learned

> Source: <https://dev.to/awaluddin/i-built-a-multi-provider-llm-router-for-my-ai-worker-heres-what-i-learned-l8d>
> Published: 2026-08-24 11:59:37+00:00

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.*
