Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(providers): rotate requests across multiple API keys per provider#516

Merged
SantiagoDePolonia merged 2 commits into
mainfrom
feat/key-rotation
Jul 9, 2026
Merged

feat(providers): rotate requests across multiple API keys per provider#516
SantiagoDePolonia merged 2 commits into
mainfrom
feat/key-rotation

Conversation

@SantiagoDePolonia

@SantiagoDePolonia SantiagoDePolonia commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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.

OPENAI_API_KEY=sk-first
OPENAI_API_KEY_2=sk-second
OPENAI_API_KEY_3=sk-third

Or in config.yaml:

providers:
  openai:
    type: openai
    api_keys:
      - "${OPENAI_API_KEY}"
      - "${OPENAI_API_KEY_2}"

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 in config.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 — llmclient calls headerSetter inside buildRequest, 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 into ProviderOptions.Keys. Providers pick it up with opts.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

  • Rotates: chat/responses/embeddings/audio/images/batches and provider-native passthrough (all share the header hook), plus the semantic-cache embedder, which has its own auth path. Embeddings have no prompt cache to lose, so rotating there is pure upside.
  • Realtime websockets pick a key when the session opens and hold it for the session's life.
  • Retries advance the ring, so a request throttled with 429 retries under a different key rather than hammering the one just throttled.
  • Unaffected: keyless providers (Ollama, vLLM) and providers that authenticate another way (Vertex, Bedrock ignore these vars; Gemini rotates only on its api_key auth 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:

Env var Means
OPENAI_API_KEY_2 key 2 of provider openai
OPENAI_EU_API_KEY_2 key 2 of provider openai-eu
OPENAI_REGION_2_API_KEY the sole key of provider openai-region-2

_1 is 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 Authorization header it receives:

  • Three keys → upstream saw sk-alpha → sk-beta → sk-gamma cycling exactly.
  • One key → same key every time (prompt caching preserved).
  • 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), and openai/keyrotation_test.go (end-to-end header assertions incl. retry-uses-next-key and keyless-sends-no-credential).

Full suite, -race, and golangci-lint clean.

Docs

New page docs/providers/key-rotation.mdx registered in the Providers tab, cross-linked from providers/overview. Includes a comparison table steering users who want several keys and prompt caching toward suffixed providers behind a round_robin virtual model.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added provider API key rotation using multiple keys per provider with round-robin selection via numbered environment variables and api_keys in YAML.
    • Added documentation and examples for setup, numbering rules, and behavior (including that rotation disables provider prompt caching).
  • Bug Fixes
    • Improved request and realtime/websocket authentication so retries and live sessions use the correct rotated key.
    • Updated configuration handling to drop empty/unresolved keys and avoid registering providers without usable credentials.
  • Documentation
    • Expanded guides, provider overview notes, and template/config examples to reflect multi-key configuration.

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]>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ab1d123e-a7c8-43a3-a4c8-5754f9679db5

📥 Commits

Reviewing files that changed from the base of the PR and between f864901 and e41e906.

📒 Files selected for processing (2)
  • internal/providers/config.go
  • internal/providers/keyring_config_test.go

📝 Walkthrough

Walkthrough

This PR adds multi-key API rotation support across GoModel. A new Keyring type provides thread-safe round-robin key selection. Provider config resolution parses numbered <PROVIDER>_API_KEY_<n> env vars and api_keys YAML entries. Providers and the embedding client now use shared keyrings, and documentation is updated.

Changes

API key rotation feature

Layer / File(s) Summary
Keyring type and factory wiring
internal/providers/keyring.go, internal/providers/keyring_test.go, internal/providers/factory.go
Introduces Keyring with Next, Primary, Len, Rotates, NewKeyring, plus ProviderOptions.Keys/Keyring() helper and factory wiring.
Config resolution for multiple API keys
internal/providers/config.go, config/providers.go, internal/providers/keyring_config_test.go
RawProviderConfig/ProviderConfig gain APIKeys; env parsing detects numbered rotation slots, resolves/dedupes/orders keys, drops unresolved placeholders, and env keys replace YAML key sets.
Anthropic, Azure, Bailian auth migration
internal/providers/anthropic/*, internal/providers/azure/*, internal/providers/bailian/*
Replaces stored apiKey with *providers.Keyring; request/realtime headers now call keys.Next(); tests assert keys.Primary().
Gemini, Ollama auth migration
internal/providers/gemini/*, internal/providers/ollama/*
Same apiKeyKeyring migration for Gemini and Ollama headers; tests updated to keys.Primary().
OpenAI-compatible, xAI, Z.ai, OpenCode Go auth migration
internal/providers/openai/*, internal/providers/xai/*, internal/providers/zai/*, internal/providers/opencodego/opencodego.go
CompatibleProvider and dependent providers use a shared Keyring; keyrotation_test.go covers rotation, single-key stability, retry-key switching, and keyless requests.
Embedding credential rotation
internal/embedding/embedding.go, internal/embedding/embedding_test.go
apiEmbedder stores a *providers.Keyring built from APIKey/APIKeys; Embed sets Authorization from keys.Next().
Documentation and config examples
docs/providers/key-rotation.mdx, docs/providers/overview.mdx, docs/docs.json, CLAUDE.md, .env.template, config/config.example.yaml
Adds a key-rotation guide and navigation entry, and updates env template/config example/overview/CLAUDE.md with rotation guidance.

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#242: Introduces the Z.ai provider with single-key Bearer auth that this PR replaces with a rotating Keyring.
  • ENTERPILOT/GoModel#392: Introduces the Bailian provider auth plumbing that this PR migrates from apiKey to providers.Keyring.
  • ENTERPILOT/GoModel#486: Both PRs modify provider auth/header wiring in OpenAI-shaped providers such as xAI and Ollama.

Poem

A rabbit hops from key to key,
round and round, so keys stay free 🔑
No single carrot bears the load,
rotation smooths the API road.
Hop, hop, rotate — never late! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: rotating provider requests across multiple API keys.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/key-rotation

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mintlify

mintlify Bot commented Jul 9, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
gomodel 🟢 Ready View Preview Jul 9, 2026, 11:07 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() calls apiKeys(), which builds maps, sorts indexes, and runs resolvedAPIKeys — all to check len > 0. Since empty() calls hasAPIKey() and apiKeys() is also called later in rawConfig/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 the empty() 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

📥 Commits

Reviewing files that changed from the base of the PR and between ebd8320 and f864901.

📒 Files selected for processing (35)
  • .env.template
  • CLAUDE.md
  • config/config.example.yaml
  • config/providers.go
  • docs/docs.json
  • docs/providers/key-rotation.mdx
  • docs/providers/overview.mdx
  • internal/embedding/embedding.go
  • internal/embedding/embedding_test.go
  • internal/providers/anthropic/anthropic.go
  • internal/providers/anthropic/anthropic_test.go
  • internal/providers/azure/azure.go
  • internal/providers/azure/realtime.go
  • internal/providers/bailian/bailian.go
  • internal/providers/bailian/realtime.go
  • internal/providers/config.go
  • internal/providers/factory.go
  • internal/providers/gemini/gemini.go
  • internal/providers/gemini/gemini_test.go
  • internal/providers/keyring.go
  • internal/providers/keyring_config_test.go
  • internal/providers/keyring_test.go
  • internal/providers/ollama/ollama.go
  • internal/providers/ollama/ollama_test.go
  • internal/providers/openai/compatible_provider.go
  • internal/providers/openai/keyrotation_test.go
  • internal/providers/openai/openai.go
  • internal/providers/openai/openai_test.go
  • internal/providers/openai/realtime.go
  • internal/providers/opencodego/opencodego.go
  • internal/providers/xai/realtime.go
  • internal/providers/xai/xai.go
  • internal/providers/xai/xai_test.go
  • internal/providers/zai/realtime.go
  • internal/providers/zai/zai.go

@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 97.02381% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/embedding/embedding.go 33.33% 2 Missing ⚠️
internal/providers/config.go 97.80% 2 Missing ⚠️
internal/providers/opencodego/opencodego.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

This PR appears safe to merge with low risk.

The rotation mechanism is small, uses an atomic counter, and preserves existing single-key behavior. Provider constructors consistently fall back for non-factory use. Targeted tests cover config normalization, key ordering, retries, and provider header behavior. No blocking correctness or security issues were identified.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the mock-upstream end-to-end test and confirmed it passed with a complete Authorization trace.
  • Validated the OpenAI key rotation and retry tests together with the generated harness; all tests passed.
  • Verified suffixed provider numeric disambiguation completed successfully.
  • Verified the generated Go harness trex_keyrotation_e2e_test.go exists and was used to drive the runtime proof.

View all artifacts

T-Rex Ran code and verified through T-Rex

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Env as Env/YAML config
participant Resolver as Provider config resolver
participant Factory as ProviderFactory
participant Ring as Keyring
participant Client as Provider HTTP client
participant Upstream as Provider API

Env->>Resolver: "api_key + api_keys / *_API_KEY_<n>"
Resolver->>Resolver: trim, drop unresolved, deduplicate, order keys
Resolver->>Factory: "ProviderConfig{APIKey, APIKeys}"
Factory->>Ring: NewKeyring(APIKeys...)
Factory->>Client: "ProviderOptions{Keys: Ring}"
Client->>Ring: Next() in header hook
Ring-->>Client: next API key
Client->>Upstream: outbound request with credential
alt retry / next request
    Client->>Ring: Next()
    Ring-->>Client: following API key
    Client->>Upstream: retried/new request with rotated credential
end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Env as Env/YAML config
participant Resolver as Provider config resolver
participant Factory as ProviderFactory
participant Ring as Keyring
participant Client as Provider HTTP client
participant Upstream as Provider API

Env->>Resolver: "api_key + api_keys / *_API_KEY_<n>"
Resolver->>Resolver: trim, drop unresolved, deduplicate, order keys
Resolver->>Factory: "ProviderConfig{APIKey, APIKeys}"
Factory->>Ring: NewKeyring(APIKeys...)
Factory->>Client: "ProviderOptions{Keys: Ring}"
Client->>Ring: Next() in header hook
Ring-->>Client: next API key
Client->>Upstream: outbound request with credential
alt retry / next request
    Client->>Ring: Next()
    Ring-->>Client: following API key
    Client->>Upstream: retried/new request with rotated credential
end
Loading

Reviews (1): Last reviewed commit: "feat(providers): rotate requests across ..." | Re-trigger Greptile

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]>
@SantiagoDePolonia SantiagoDePolonia merged commit 20977e1 into main Jul 9, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants