Skip to content

Add rotation strategies + key health engine for multi-key providers - #1191

Open
FiredMosquito831 wants to merge 5 commits into
Alishahryar1:mainfrom
FiredMosquito831:feat/key-rotation-engine
Open

Add rotation strategies + key health engine for multi-key providers#1191
FiredMosquito831 wants to merge 5 commits into
Alishahryar1:mainfrom
FiredMosquito831:feat/key-rotation-engine

Conversation

@FiredMosquito831

@FiredMosquito831 FiredMosquito831 commented Jul 19, 2026

Copy link
Copy Markdown

Split out from the too-large #1164 — this PR contains only the rotation strategies and health engine.

Depends on / includes #1190 (multi-key support) — only the last commit (feat(rotation): three rotation strategies with full key health engine) is new; please merge after #1190.

What it adds

  • Three rotation strategies per provider: round_robin (spread across healthy keys), least_used (fewest requests first), failover (stick to one key until it fails; on_error kept as an alias). Selected via *_ROTATION or the dashboard select next to Manage keys.
  • Health engine per key: tiered cooldowns (10s→30s→60s→120s), circuit breaker after 3 consecutive failures with single-probe half-open recovery, escalating auth lockouts for 401/403 (5min→1h→24h), request/failure metrics, and a clear retry-in-Ns error when every key is benched.
  • Dashboard: rotation select rendered inside the credential key manager, plus live per-key health badges (state, "back in Ns", request/failure counts) fed by a new health field on the keys endpoint (read from the live RotatingProvider).

1167 tests pass, including strategy selection, cooldown/lockout escalation, circuit recovery, and all-benched behavior.

Greptile Summary

This PR adds credential rotation and per-key health tracking for multi-key providers. The main changes are:

  • Round-robin, least-used, failover, and single-key selection policies.
  • Per-key cooldown, circuit-breaker, lockout, and recovery state.
  • A streaming provider wrapper that retries before the first response chunk.
  • Admin APIs and dashboard controls for key management and health display.

Confidence Score: 4/5

The streaming rotation lifecycle can use unintended credentials and permanently strand half-open keys. These paths need fixes before merging.

The default single policy can retry with a secondary key. Cancellation, mid-stream failure, and cleanup failure can leave a probe reserved forever. An empty upstream stream is silently treated as a completed response. Key deletion can temporarily display another credential's health. The required package version and lockfile updates are missing.

src/free_claude_code/providers/runtime/rotating.py, src/free_claude_code/api/admin_routes.py, pyproject.toml, and uv.lock

T-Rex T-Rex Logs

What T-Rex did

  • Ran a focused async pytest harness against RotatingProvider with two repository-compatible providers to verify the single-policy failover behavior.
  • Validated HALF_OPEN probe behavior by moving key 0 into HALF_OPEN and simulating a retryable upstream failure, observing a leaked reservation and key 1 acquisition.
  • Executed a test harness against an empty stream to confirm a successful response with no chunks and no second-provider invocation.
  • Checked production release metadata handling and confirmed no version bump or changes to pyproject.toml or uv.lock between base and head.
  • Observed the UI rotation flow, including status badges and least_used API submission, to verify runtime presentation and configuration changes.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src/free_claude_code/providers/credential_rotation.py Adds selection policies, per-key metrics, cooldowns, circuit states, lockouts, and half-open recovery.
src/free_claude_code/providers/runtime/rotating.py Adds streaming rotation, but single-policy retries and incomplete stream exits can select unintended keys or strand probe state.
src/free_claude_code/providers/runtime/factory.py Builds one provider per key and wraps multi-key configurations in the rotation engine.
src/free_claude_code/providers/runtime/config.py Parses comma-separated credentials and resolves the configured rotation policy.
src/free_claude_code/api/admin_routes.py Adds key-management and health endpoints, with temporary index misalignment after non-final key deletion.
src/free_claude_code/api/admin_static/admin.js Adds rotation controls, key editing, detailed API errors, and per-key health badges.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant Wrapper as RotatingProvider
participant Health as RotationState
participant KeyA as Provider A
participant KeyB as Provider B

Client->>Wrapper: stream request
Wrapper->>Health: acquire()
Health-->>Wrapper: key index
Wrapper->>KeyA: start stream
alt failure before first chunk
    KeyA-->>Wrapper: retryable error
    Wrapper->>Health: report_failure()
    Wrapper->>Health: acquire()
    Health-->>Wrapper: next key
    Wrapper->>KeyB: retry request
else stream starts
    KeyA-->>Wrapper: chunks
    Wrapper-->>Client: chunks
    Wrapper->>Health: report_success()
else all keys benched
    Health-->>Wrapper: -1
    Wrapper-->>Client: unavailable with retry delay
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 Client
participant Wrapper as RotatingProvider
participant Health as RotationState
participant KeyA as Provider A
participant KeyB as Provider B

Client->>Wrapper: stream request
Wrapper->>Health: acquire()
Health-->>Wrapper: key index
Wrapper->>KeyA: start stream
alt failure before first chunk
    KeyA-->>Wrapper: retryable error
    Wrapper->>Health: report_failure()
    Wrapper->>Health: acquire()
    Health-->>Wrapper: next key
    Wrapper->>KeyB: retry request
else stream starts
    KeyA-->>Wrapper: chunks
    Wrapper-->>Client: chunks
    Wrapper->>Health: report_success()
else all keys benched
    Health-->>Wrapper: -1
    Wrapper-->>Client: unavailable with retry delay
end
Loading

Reviews (1): Last reviewed commit: "feat(rotation): three rotation strategie..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

Context used:

  • Context used - CLAUDE.md (source)

Contributor added 5 commits July 19, 2026 04:14
…tion

Providers now accept a comma-separated list of keys in their credential env var (e.g. NVIDIA_NIM_API_KEY=k1,k2) plus an optional *_ROTATION policy (single|round_robin|on_error).

A RotatingProvider runtime wrapper builds one sub-provider per key, spreads requests across keys (round_robin), and fails over to the next key when the first attempt errors before any output is streamed (on_error, with per-key backoff). Plain 400s are not rotated. Admin UI gains a per-provider Key Rotation select field.
Every provider credential field in the admin dashboard now has a Manage keys panel: masked key list, add-key input, and per-key removal.

Backed by /admin/api/credentials/{env_key}/keys endpoints (list/add/delete) that persist through the existing admin apply pipeline, so changes take effect immediately without restart. Keys are always masked in API responses; process-env-sourced credentials are read-only.
Backports the multi-provider proxy's rotation system:

- Policies: single, round_robin, least_used, failover (on_error kept as
  an alias of failover), selectable per provider via *_ROTATION.
- Health engine per key: tiered cooldowns (10s-120s), circuit breaker
  after 3 consecutive failures with single-probe half-open recovery,
  escalating auth lockouts for 401/403 (5min-1h-24h), request/failure
  metrics, and a 503-style error with retry wait when every key is
  benched.
- Dashboard: rotation policy select next to Manage keys, and live
  per-key health badges (state + 'back in Ns' + request/failure counts)
  in the key manager, fed by the keys endpoint's new health field.
Comment on lines +99 to +106
if index in attempted:
remaining = [
i for i in range(len(self._providers)) if i not in attempted
]
if not remaining:
break
index = remaining[0]
attempted.add(index)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Single Policy Falls Through To Failover

With multiple configured keys and the default single policy, acquire() always returns key 0. After a retryable failure, this branch sees key 0 in attempted and directly selects key 1, so a policy documented as using only the first key silently performs failover.

Artifacts

Repro: executable focused async pytest harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: verbose pytest output showing retryable key 0 failure and key 1 failover

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +126 to +132
try:
yield first_chunk
async for chunk in iterator:
yield chunk
finally:
await maybe_await_aclose(iterator)
await self._state.report_success(index)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Half-Open Probe Slot Stays Reserved

When a half-open stream is cancelled, fails after its first chunk, or raises during cleanup, control leaves through finally without calling either health reporter. The key remains HALF_OPEN with is_probing=True, so every later acquisition skips it permanently and the provider can remain unavailable after its cooldown has expired.

Artifacts

Repro: focused async harness for a post-first-chunk HALF_OPEN probe failure

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: verbose pytest output showing the leaked reservation and subsequent acquisition failure

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +116 to +119
except StopAsyncIteration:
return
except Exception as error:
last_error = error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Empty Stream Becomes Successful Response

If a sub-provider ends before producing its first chunk, this branch returns without reporting success or failure. The request therefore completes as an empty response instead of surfacing an upstream failure or trying another eligible key, and a half-open key also remains stuck in its probing state.

Artifacts

Repro: focused executable async RotatingProvider test harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Stack trace captured during the T-Rex run

  • Keeps the raw stack trace available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +217 to +219
snapshots = provider.key_health()
for i in range(min(len(keys), len(snapshots))):
health[i] = snapshots[i]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Deleted Key Shifts Health Ownership

After deleting a non-final key, the persisted key list changes before an existing provider generation is necessarily gone. Deleting B from [A, B, C] leaves [A, C], but this index copy maps B's old snapshot onto C, so the dashboard shows the deleted credential's health and counters beside the wrong surviving key.

Comment on lines +158 to +164
:class:`RotatingProvider` that applies the configured rotation policy.
"""
descriptor = PROVIDER_CATALOG.get(provider_id)
if descriptor is None:
raise UnknownProviderError.for_provider(provider_id, PROVIDER_CATALOG)

config = build_provider_config(descriptor, settings)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Production Feature Lacks Version Bump

This adds a user-visible provider and configuration capability, but the change does not update pyproject.toml or regenerate uv.lock. The repository's release rules require both updates in the same production change, with a minor version bump for a new capability.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Repro: executable base/head release metadata harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: failing base/head version and lockfile comparison

  • Keeps the command output available without making the summary code-heavy.

Repro: head uv lock consistency command output

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

@ichixsanu

Copy link
Copy Markdown

This is a good idea, ive thought of doing this for nvidia nim keys, by editing the nvidia nim default base to my multi key proxy. It works pretty good.

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