feat(providers): rotate requests across multiple API keys per provider#516
Conversation
Configure several API keys for one provider and GoModel spreads outbound requests across them round robin, raising the per-key rate limit before a provider starts returning 429. Keys come from `<PROVIDER>[_SUFFIX]_API_KEY_<n>` env vars (numbered from 2; `_1` is accepted as a synonym for the unsuffixed key) or `api_keys:` in config.yaml. The trailing number names a key, not a provider: OPENAI_API_KEY_2 is key 2 of `openai`, while OPENAI_REGION_2_API_KEY remains the sole key of provider `openai-region-2`. Every provider already resolved its credential through a per-request header hook, so rotation hooks in there rather than at construction: providers.Keyring replaces the captured API key string and the factory hands each provider instance one shared ring. No provider constructor signature changes, and constructors invoked outside the factory fall back to a single-key ring. Rotation advances per outbound HTTP request, including retries, so a request throttled with 429 retries under the next key. Realtime websocket sessions pick a key per session. Keyless providers (Ollama, vLLM) and providers that authenticate another way (Vertex, Bedrock) are unaffected. User-visible caveat, documented prominently: rotation defeats provider prompt caching, since providers scope the cache to the key that filled it. One configured key behaves exactly as before, so nothing regresses by default. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis PR adds multi-key API rotation support across GoModel. A new ChangesAPI key rotation feature
Estimated code review effort: 4 (Complex) | ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/providers/config.go (1)
173-222: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value
hasAPIKey()allocates the full ordered key list just to check non-emptiness.
hasAPIKey()callsapiKeys(), which builds maps, sorts indexes, and runsresolvedAPIKeys— all to checklen > 0. Sinceempty()callshasAPIKey()andapiKeys()is also called later inrawConfig/overlayProviderEnvValues, the work is duplicated per provider group. This only runs at startup so the cost is negligible, but a lightweight check (strings.TrimSpace(v.APIKey) != "" || len(v.APIKeysByIndex) > 0) would avoid the allocation entirely if unresolved-key handling isn't a concern at theempty()gate.This is a minor observation, not a blocking issue — the current approach correctly accounts for unresolved placeholders.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/providers/config.go` around lines 173 - 222, hasAPIKey() is doing unnecessary work by calling apiKeys() just to determine whether any credential exists. Update hasAPIKey() in providerEnvValues to use a lightweight non-empty check against APIKey and APIKeysByIndex instead of building the full ordered key list, and keep empty() relying on that cheaper path. Preserve the current behavior for apiKeys() and resolvedAPIKeys(), but avoid map/sort/allocation work in the non-empty probe.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/providers/config.go`:
- Around line 173-222: hasAPIKey() is doing unnecessary work by calling
apiKeys() just to determine whether any credential exists. Update hasAPIKey() in
providerEnvValues to use a lightweight non-empty check against APIKey and
APIKeysByIndex instead of building the full ordered key list, and keep empty()
relying on that cheaper path. Preserve the current behavior for apiKeys() and
resolvedAPIKeys(), but avoid map/sort/allocation work in the non-empty probe.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: c46fde54-4fd6-4118-8e34-db88f2a18008
📒 Files selected for processing (35)
.env.templateCLAUDE.mdconfig/config.example.yamlconfig/providers.godocs/docs.jsondocs/providers/key-rotation.mdxdocs/providers/overview.mdxinternal/embedding/embedding.gointernal/embedding/embedding_test.gointernal/providers/anthropic/anthropic.gointernal/providers/anthropic/anthropic_test.gointernal/providers/azure/azure.gointernal/providers/azure/realtime.gointernal/providers/bailian/bailian.gointernal/providers/bailian/realtime.gointernal/providers/config.gointernal/providers/factory.gointernal/providers/gemini/gemini.gointernal/providers/gemini/gemini_test.gointernal/providers/keyring.gointernal/providers/keyring_config_test.gointernal/providers/keyring_test.gointernal/providers/ollama/ollama.gointernal/providers/ollama/ollama_test.gointernal/providers/openai/compatible_provider.gointernal/providers/openai/keyrotation_test.gointernal/providers/openai/openai.gointernal/providers/openai/openai_test.gointernal/providers/openai/realtime.gointernal/providers/opencodego/opencodego.gointernal/providers/xai/realtime.gointernal/providers/xai/xai.gointernal/providers/xai/xai_test.gointernal/providers/zai/realtime.gointernal/providers/zai/zai.go
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
hasAPIKey answered a yes/no question by building the full ordered key list --
a map, a sort, and two slices -- and then discarding it. empty() asks it for
every provider env group.
Probe the fields directly instead. The check stays equivalent because it reuses
HasResolvedProviderValue, so a blank, whitespace, or unresolved `${VAR}` value
still reads as "no key" rather than merely "field is set"; a plain non-empty
test on APIKey/APIKeysByIndex would have disagreed on exactly those inputs. A
table test pins the two spellings together.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
What
Configure several API keys for one provider and GoModel spreads outbound requests across them round robin. Each key carries a fraction of the traffic, raising the per-key rate limit you hit before a provider starts returning
429.Or in
config.yaml:User-visible impact
Rotation defeats provider prompt caching. Providers scope their prompt cache to the key that filled it, so consecutive requests under different keys never hit a warm cache. Rotate to lift rate limits, not to save cost. This is called out in a
<Warning>at the top of the new docs page, in.env.template, and inconfig.example.yaml.Nothing changes by default. Rotation is off until a second key is configured; one key behaves exactly as it always has, warm cache included.
Design
Every provider already resolved its credential through a per-request header hook —
llmclientcallsheaderSetterinsidebuildRequest, which sits inside the retry loop. The credential never had to be a string captured at construction; it just always was one.So rotation hooks in there.
providers.Keyring(nil-safe, atomic round-robin) replaces the captured key string, and the factory builds one ring per provider instance intoProviderOptions.Keys. Providers pick it up withopts.Keyring(apiKey), which falls back to a single-key ring when constructed outside the factory (tests,NewWithHTTPClient). No provider constructor signature changes. Because every client a provider builds shares the one ring, rotation stays even across all of its endpoints.This also let
CompatibleProvider.apiKey— a write-only dead field — become the thing that carries the rotation.Provider-specific behavior
429retries under a different key rather than hammering the one just throttled.api_keyauth branch, not Vertex OAuth).The ambiguity worth reviewing
The trailing number collides with the existing suffixed-provider mechanism. The parser disambiguates, and both cases are pinned in tests and verified in the running binary:
OPENAI_API_KEY_2openaiOPENAI_EU_API_KEY_2openai-euOPENAI_REGION_2_API_KEYopenai-region-2_1is accepted as a synonym for the unsuffixed key rather than silently ignored — dropping a key someone deliberately set is a costly footgun, and Postel's Law is this repo's first principle.Unresolved
${VAR}placeholders are dropped rather than forwarded as literal credentials; a provider left with no keys is not registered, exactly as before.No DB schema
Keys come from env/YAML like every other provider credential. The only state is an in-memory counter, so there's no table and no migration — persisting secrets for zero functional gain. Counters are per-process and reset on restart, which is fine: the goal is to spread load, not to sequence it exactly.
Verification
Beyond unit tests, I ran the real gateway against a mock upstream that logs the
Authorizationheader it receives:sk-alpha → sk-beta → sk-gammacycling exactly.OPENAI_REGION_2_API_KEY→ registered a separate provider, not a rotation slot.Tests added:
keyring_test.go(incl. a concurrency test asserting an even split under-race),keyring_config_test.go(env/YAML resolution, numeric-not-lexical slot ordering, dedup, gaps, the suffix ambiguity above), andopenai/keyrotation_test.go(end-to-end header assertions incl. retry-uses-next-key and keyless-sends-no-credential).Full suite,
-race, andgolangci-lintclean.Docs
New page
docs/providers/key-rotation.mdxregistered in the Providers tab, cross-linked fromproviders/overview. Includes a comparison table steering users who want several keys and prompt caching toward suffixed providers behind around_robinvirtual model.🤖 Generated with Claude Code
Summary by CodeRabbit
api_keysin YAML.