The 429 that started it
Some time in early 2026 I had a small LLM side project running against Gemini. One key, one .env file, one GEMINI_API_KEY line, the standard setup. It worked until the day I hit the free-tier daily quota by about 2 PM, and then the project stopped answering until UTC midnight.
I had a GROQ_API_KEY sitting in the same .env file, from a different project. If the code had known about it, the project would have kept working. It did not know, because nothing in that project's code was written to know. os.getenv("GEMINI_API_KEY") is a one-key idea.
That afternoon I opened a new repo called aikeyrotator and wrote the smallest thing that would fix the specific problem I had just hit: a Python library that finds every AI key you already have in your environment, sorts them by which free tier is most generous, and rotates when one starts returning 429s.
The full vault came later. This post is about the script that came first.
What the library actually did
aikeyrotator is on GitHub at kaushiksaravanan/aikeyrotator. It ships as a pip package with a CLI (aikeyrotator test) and a RotatorClient you drop into code. The pyproject.toml calls it "a lightweight Python library that auto-detects AI API keys, prioritizes free tiers, and automatically rotates between providers on rate limits." That is a pretty accurate one-liner.
Under the hood there are seven small modules. detector.py walks the environment, providers.py holds a static table of every provider it knows about, prioritizer.py orders them, rotator.py does the actual failover, client.py sits on top of litellm so you get a completion() call that behaves like OpenAI's SDK but with rotation baked in, cli.py powers aikeyrotator test, and exceptions.py holds the error types.
The whole thing is about 120 KB of Python. There is no server, no database, no dashboard. It runs in-process.
Detector
KeyDetector loads .env with python-dotenv, then walks os.environ looking for variable names it recognizes. Each provider config lists the names it will answer to. Google AI Studio, for example, matches GOOGLE_AI_STUDIO_API_KEY, GOOGLE_API_KEY, and GEMINI_API_KEY, because I had all three spellings scattered across old projects and I did not want to remember which one I had used where.
For each hit it constructs a DetectedKey with the provider, the config, the raw key, and the env var name it came from. It masks the key when printing (AIza...abc4) so aikeyrotator test does not spill secrets into your terminal history.
Providers table
providers.py hardcodes 12 providers: Groq, Cerebras, Google AI Studio, OpenRouter, HuggingFace, Mistral, ElevenLabs, Cohere, NVIDIA, GitHub Models, Vercel AI, Cloudflare AI. Each one gets a free_tier_priority integer where lower is better. Groq is 1, because at the time it had the most generous free tier for fast Llama inference. Cerebras is 2. Gemini is 3.
The table also carries the litellm_prefix (groq/, cerebras/, gemini/), the default model, a list of free models, the HTTP status codes that count as rate limit ([429] for most, some providers return odd codes), and a bag of substring matches ("rate limit", "quota exceeded") to catch providers that lie in the status code and tell the truth in the body.
Prioritizer
Prioritizer sorts detected keys. By default it goes by free_tier_priority, so Groq comes before Gemini comes before OpenRouter. You can override with your own priority list, or pass a modality_filter to keep only text/image/audio providers, or plug in a PriorityRule with a custom scorer if you want to weight latency against cost or something. In practice I never used the custom rules. The static priority order was fine.
Rotator
This is where it earns the name. Rotator holds a ProviderState per key: is_available, consecutive_failures, last_failure_time, rate_limit_reset_time, backoff_until. When a call fails with a 429, mark_failure reads the Retry-After header if present, otherwise sets exponential backoff (min(2 ** failures, 300) seconds), flips is_available to False, and the next call skips to the next provider in the prioritized list. Three consecutive non-429 failures also bench a provider for five minutes.
There is an optional SQLite persistence layer (aiosqlite) so state survives process restarts. I never actually turned it on. The library was designed to be used in short-lived scripts and hackathon notebooks where losing state on restart was fine.
What the library did not have
The library solved my Tuesday-afternoon problem. It did not solve the problem I discovered on Wednesday.
No shared state across projects. aikeyrotator is a library. Two projects using it read the same .env and both think they have exclusive use of the same Gemini key. If project A hits the quota, project B still tries the same key and gets 429ed immediately. The backoff table lives in process memory. There is no coordination.
Keys still live in plaintext. Everything the library does happens after keys are already in os.environ or a .env file. The .env file is plaintext. It sits in a repo I might accidentally commit, on a laptop I might accidentally leave on a train. Rotation without secrecy is only half the problem.
No dashboard, no auth, no audit. There is nothing to look at. You do not know which key was used for which request. You do not know how many tokens you burned yesterday. If you leak a key, you rotate it by editing .env on every machine that has it.
No fleet primitive. The rotator switches between providers (Gemini to Groq to Cohere). It does not know how to hold eight Gemini keys and pick the least-recently-used one. That is a different data structure. In the library, one env var maps to one key.
No way to hand a key to code you did not write. If I deploy something to Fly.io that needs Gemini, I still have to put the raw key in a Fly secret. There is no vend-over-HTTP layer.
Each of those gaps is, in retrospect, one of the four architectural choices the CipherStack full architecture post walks through. The fleet primitive, the two auth paths, the no-secret-dump rule, and the one-machine deploy topology all exist because aikeyrotator did not have them and I got tired of noticing.
Where the code went
Some of the ideas made it forward. The provider table in CipherStack still uses the same slug set (groq, gemini, openrouter, cerebras, huggingface, mistral, cohere, nvidia, cloudflare-ai, github-models, vercel, elevenlabs). The cooldown-on-429 loop is recognizably the same pattern, though it now lives in a Postgres row instead of a ProviderState dataclass. The rule that a 60-second backoff is enough for most providers came out of watching the library's exponential backoff and noticing that the second bucket almost always succeeded.
Most of the code did not. aikeyrotator is a library that lives in your process and reads your .env. CipherStack is a service that lives on Fly.io, holds encrypted rows in Postgres, authenticates you with either a service token or an HMAC-signed cert, and hands you exactly one key per request. Different shape, different threat model, different deploy target.
I am leaving the repo up because it is honest prior art. It is what the problem looked like when I first noticed it, before I understood that "rotate between providers" was the wrong primitive and "rotate between keys within a provider" was the right one. The commit log has one entry: "Initial commit." I never came back to it, because by the time I would have, I had already started writing the vault.
If you have one Gemini key and one Groq key and want your Python side project to fail over between them without pulling in a whole vault, pip install aikeyrotator still works. If you have eight Gemini keys and four side projects that all want to share the pool, you want the vault instead.
See also
- /blog/cipherstack-lru-rotation, the state machine that replaced the
ProviderStatedataclass in this post. - /blog/cipherstack-vault-full-architecture, the full vault this library grew into once the primitive changed from "rotate providers" to "rotate keys within a provider."