Keep your AI project running when free APIs hit their limits.
LLMSwitch sits between your code and the free tiers of eight AI providers. Write one call. When a provider rate-limits you, it fails over to the next one — and remembers not to keep knocking on a door that just closed.
Two minutes to a working fallback chain
pip install ai-switch
The distribution is named ai-switch — PyPI rejected
llmswitch as too similar to an unrelated existing project. The import name is
unchanged.
Set a key for any provider you want in the rotation — LLMSwitch only uses the ones it finds:
export GROQ_API_KEY="..."
export OPENROUTER_API_KEY="..."
export NVIDIA_API_KEY="..."
Then write the call once:
from llmswitch import AIClient
client = AIClient()
print(client.chat("Write a Python web scraper"))
Your code never names a provider. That is the whole idea.
Keys and provider order
| Provider | Environment variable | Order |
|---|---|---|
groq | GROQ_API_KEY | 1 |
cerebras | CEREBRAS_API_KEY | 2 |
gemini | GEMINI_API_KEY | 3 |
openrouter | OPENROUTER_API_KEY | 4 |
nvidia | NVIDIA_API_KEY | 5 |
mistral | MISTRAL_API_KEY | 6 |
huggingface | HF_TOKEN | 7 |
ollama | OLLAMA_HOST | 8 — local last resort |
Providers without a key are skipped silently at startup.
Ollama as a local last resort
Ollama needs no API key and never rate-limits, which makes it a natural final fallback
once every cloud free tier is spent. It is opt-in: set OLLAMA_HOST to enable
it, so anyone without a local server does not pay for a failed attempt on every request.
export OLLAMA_HOST="http://localhost:11434/v1"
To pick the set and the order yourself:
from llmswitch import AIClient
from llmswitch.providers import GroqProvider, NVIDIAProvider
client = AIClient(providers=[GroqProvider(), NVIDIAProvider()])
One request, as many attempts as it takes
A single chat() call walks the provider list until one answers:
Two failure kinds drive this. RateLimitError means you have been throttled or
have burned the quota. ProviderUnavailableError means the provider is down,
unreachable, or rejected the request. Anything else propagates untouched — a bug in your
payload should not silently cost you three API calls.
If every provider fails, LLMSwitch raises ProviderError naming each failure, so you can see what actually went wrong.
It remembers which doors just closed
Falling back on every request is not enough. Without memory, a rate-limited Groq is called again on the next request, and the one after that — burning a wasted round-trip every time before failing over. LLMSwitch rests a failed provider and moves it to the back of the queue:
client.chat("first") # groq 429 -> falls back to openrouter
client.chat("second") # groq skipped entirely; openrouter serves it
Inspect the live picture at any time with client.status():
How long a provider rests
- If the provider sent a
Retry-Afterheader, that wins — it knows better than any guess. - Otherwise rate limits back off exponentially from 60s, doubling per consecutive failure, capped at 15 minutes.
- Transient outages start at 10s and cap at 5 minutes, since they usually clear fast.
- One success resets the streak, so a brief blip does not poison a provider for the rest of the session.
Stop memorizing provider-specific model names
The same model is named three different things by three providers. Ask for the alias and LLMSwitch resolves it per provider:
| Alias | Groq | OpenRouter | NVIDIA |
|---|---|---|---|
fast-model |
llama-3.1-8b-instant |
meta-llama/llama-3.1-8b-instruct |
meta/llama-3.1-8b-instruct |
smart-model |
llama-3.3-70b-versatile |
meta-llama/llama-3.3-70b-instruct |
meta/llama-3.3-70b-instruct |
client.chat("hello", model="fast-model") # resolved per provider
client.chat("hello", model="llama-3.1-8b-instant") # raw names pass through
fast-model is verified against live Groq and
OpenRouter APIs; smart-model is not yet verified — check the current
catalog before relying on it.
Tokens as they arrive, on either transport
with AIClient() as client:
for token in client.stream("Write a haiku about failover"):
print(token, end="", flush=True)
What happens when a stream breaks
Fallback is transparent only up to the first token. Once tokens are in your hands
they cannot be un-yielded, so a later failure raises StreamInterrupted carrying
the partial text — rather than silently restarting the answer on another provider and making
your reader watch it begin again mid-sentence.
except StreamInterrupted as exc:
print(exc.partial) # everything received before the break
print(exc.provider) # which provider dropped it
Async
AsyncAIClient shares the sync client's routing core, so fallback, cooldowns and
retry behave identically — only the transport differs.
async with AsyncAIClient() as client:
print(await client.chat("hello"))
async for token in client.stream("count to five"):
print(token, end="")
Connections
Each provider holds a pooled connection, reused across calls. Close the client when done — the context manager above is the tidy path; forgetting it still works, just cleaned up later.
Retries
A timeout usually means one unlucky request rather than a dead provider, so the same provider
gets one retry before failover. A 429 is never retried — the provider has
explicitly said stop.
The whole surface
AIClient(providers=None, health=None)
Both arguments are injectable, which is what makes the router testable without network access.
| Member | Returns | Notes |
|---|---|---|
chat(message, model="fast-model", **kwargs) |
str |
Accepts a string or an OpenAI-style messages list. Extra kwargs (temperature, max_tokens) forward to the provider. |
stream(message, model="fast-model", **kwargs) |
Iterator[str] |
Yields tokens. Raises StreamInterrupted if a provider dies after emitting. |
close() / await aclose() |
None |
Release pooled connections. Both clients are context managers. |
status() |
dict |
Availability, cooldown remaining, success/failure counts, last error. |
providers | list | The resolved provider instances. |
health | HealthTracker | Shareable across clients. |
Exceptions
| Type | Raised when |
|---|---|
ProviderError | Base type; also raised when every provider has failed. |
RateLimitError | HTTP 429 or quota exhausted. Carries .retry_after. |
ProviderUnavailableError | Connection failure, 5xx, or a rejected request. |
ProviderTimeoutError | Request timed out. Subclasses the above; earns one retry. |
StreamInterrupted | Stream failed after emitting. Carries .partial and .provider. |
Writing an adapter
Any provider speaking the OpenAI chat-completions shape needs three lines:
from llmswitch.providers.base import OpenAICompatibleProvider
class MyProvider(OpenAICompatibleProvider):
name = "myprovider"
api_base = "https://api.example.com/v1"
api_key_env = "MYPROVIDER_API_KEY"
The base class handles auth headers, the User-Agent, Retry-After parsing, and
mapping status codes onto the two error types. For a provider with its own wire format
(Gemini, for instance), subclass Provider and implement chat()
directly — just raise the same two exceptions so the router can do its job.
Python-urllib request gets a 403. LLMSwitch sends an explicit
User-Agent for this reason. Worth knowing if you write your own adapter.
What is built and what is next
Retry-After support, and status(). Published to PyPI. Suite now 64 tests.AsyncAIClient, pooled connections, retry-on-timeout, and CI green across Python 3.9–3.13 against real httpx. Suite now 150 tests.llmswitch.toml config, routing strategies beyond in-order (fastest-first, round-robin, quota-aware), and a script validating model aliases against providers' live catalogs.llmswitch status CLI.