LlmKeyPool 0.1.0

Prefix Reserved
dotnet add package LlmKeyPool --version 0.1.0
                    
NuGet\Install-Package LlmKeyPool -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LlmKeyPool" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LlmKeyPool" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="LlmKeyPool" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LlmKeyPool --version 0.1.0
                    
#r "nuget: LlmKeyPool, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LlmKeyPool@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LlmKeyPool&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=LlmKeyPool&version=0.1.0
                    
Install as a Cake Tool

LlmKeyPool

CI NuGet License: MIT

A provider-agnostic API key pool for .NET. Rotation, quota-aware cooldown, per-key concurrency limits, health tracking, and state that survives a restart — for applications holding several LLM API keys that need requests to keep flowing when one key is rate limited.

                       ┌──────────────────────────────┐
   request ───────────▶│          IKeyPool            │
                       │                              │
                       │  select ──▶ lease ──▶ report │
                       └───────┬──────────────────────┘
                               │
        ┌──────────┬───────────┼───────────┬──────────┐
        ▼          ▼           ▼           ▼          ▼
     key #1     key #2      key #3      key #4     key #5
    cooling     cooling    available    disabled   available
   RPD · 6h12m  TPM · 18s  in-flight:2    401      in-flight:0

Why not just Polly?

Polly answers "this request failed — how should I retry it?". LlmKeyPool answers "which credential should this request use, and which ones are burnt right now?" They compose; this is not a replacement.

A 429 from an LLM provider is not a generic transient fault. It names a dimension — requests or tokens, per minute or per day — and implies a recovery window that ranges from 20 seconds to the provider's next daily reset. Backing off on the same key is the wrong move: the key is out of budget, not momentarily unlucky.

// What retry-only resilience does with a spent daily quota:
key A → 429 → wait 1s → 429 → wait 2s → 429 → wait 4s → 429 → give up

// What a key pool does:
key A → 429 "requests per day" → cool A until 00:00 Pacific → key B → 200

Install

dotnet add package LlmKeyPool

Targets net8.0 and net9.0. Depends only on Microsoft.Extensions.* abstractions.

Quick start

builder.Services.AddLlmKeyPool("gemini", pool =>
{
    pool.Provider = "gemini";
    pool.AddKeys(builder.Configuration["Gemini:ApiKeys"]!);   // CSV, newlines or semicolons
    pool.FailurePolicy = KeyFailurePolicies.Gemini;
    pool.Selector = KeySelectors.LeastRecentlyUsed;
    pool.MaxConcurrencyPerKey = 4;
});

Then run work through the pool. It picks the key, you make the call, you report what happened:

public sealed class Translator(IKeyPoolProvider pools, HttpClient http)
{
    private readonly IKeyPool _pool = pools.Get("gemini");

    public Task<string> TranslateAsync(string text, CancellationToken ct) =>
        _pool.ExecuteAsync(async (lease, token) =>
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint)
            {
                Content = JsonContent.Create(new { text }),
            };
            request.Headers.Add("x-goog-api-key", lease.Key.Value);

            using var response = await http.SendAsync(request, token);

            if (!response.IsSuccessStatusCode)
            {
                var body = await response.Content.ReadAsStringAsync(token);
                lease.ReportFailure((int)response.StatusCode, response.ToHeaderDictionary(), body);
                response.EnsureSuccessStatusCode();   // throw to rotate onto the next key
            }

            var result = await response.Content.ReadFromJsonAsync<Result>(token);
            lease.ReportSuccess(result!.Usage.TotalTokens);
            return result.Text;
        }, cancellationToken: ct);
}

ExecuteAsync rents a key, runs your delegate, classifies any throw through the pool's failure policy, cools the key for the right window, and retries on the next key — up to MaxKeyAttempts.

What the failure policy knows

This is the part a generic resilience library deliberately leaves open.

Signal Handling
Retry-After Honoured first, as seconds or as an HTTP-date
x-ratelimit-reset-tokens / -requests Read as Go-style durations (6m0s, 88ms)
anthropic-ratelimit-*-reset Read as RFC 3339 timestamps
"Please retry in 58.09s" Parsed — but only when it reads as a wait hint, not from any digits in the message
TPD / RPD / "tokens per day" Cooled until the provider's own daily reset — midnight Pacific for Gemini, not UTC
TPM / RPM The provider's own number wins; a one-minute floor otherwise
insufficient_quota Key disabled, not cooled — waiting does not refill a prepaid balance
401 / 403 Disabled. A quota-shaped 403 is cooled instead, since some providers report a spent budget that way
5xx, transport faults Rotated without penalising the credential
400 / 404 Fatal — a malformed request fails identically on every key, so the pool is not burnt rediscovering your bug
Rate limited, dimension unstated Conservative window that doubles with the failure streak. Guessing short recreates a refill-drain loop that hammers a spent key forever

Built-in profiles: Generic, Gemini, OpenAI. Add your own with QuotaPolicyProfile, or mix providers in one pool with CompositeFailurePolicy.

Selection strategies

Strategy Use it when
RoundRobin (default) Even rotation
LeastRecentlyUsed RPM-bound free tiers — maximises spacing between hits on one key
LeastLoaded Long, variable-latency calls

The round-robin cursor is reserved with Interlocked.Increment when the order is built, not when a response arrives. Advancing on completion lets every concurrent caller read the same cursor and stampede one key while the rest of the pool sits idle.

Inspecting the pool

app.MapGet("/keypool", (IKeyPoolProvider pools) => pools.Get("gemini").Inspect());
key           status     quota   resumes in   ok/err   tokens
────────────  ─────────  ──────  ───────────  ───────  ──────
AIza~4f2c1d9a cooling    RPD     6h 12m       412/3    1.2M
AIza~9a30bb17 cooling    TPM     18s          388/1    980K
AIza~7c1e0d55 available  —       —            401/0    1.1M
AQ.A~d1b93f60 disabled   Auth    —            0/1      0

KeyState carries no secret material, so it is safe to serialise into a status endpoint. Keys are identified by a fingerprint — a four-character provider prefix plus a hash — and ApiKey.ToString() is masked, so a stray log line cannot leak a credential.

Persistence

A day-class cooldown is worthless if it evaporates on redeploy: the next request goes straight back into an empty budget.

pool.PersistTo(new InMemoryKeyStateStore());   // FileKeyStateStore and Redis land in v0.2/v0.3

Snapshots are keyed by fingerprint and contain no key material, so they are safe to write to a mounted volume or a shared cache. On import, cooldowns merge forward — a restored snapshot can extend a lock but never shorten one.

Testing

Every cooldown computation goes through TimeProvider, so the suite drives the clock:

var time = new FakeTimeProvider(start);
// ... cool a key for 8 hours ...
time.Advance(TimeSpan.FromHours(8));
Assert.Equal(KeyStatus.Available, pool.Inspect()[0].Status);

93 tests covering day-long quota windows and DST transitions run in about 100 ms.

Scope

Out of scope on purpose:

  • Sending LLM requests or parsing responses — that belongs to your provider SDK.
  • Retry and backoff within one key — Polly owns that and composes cleanly.
  • Cost accounting and model routing — the pool reports token counts and stops there.
  • Running as a gateway process — this is an in-process library.

A note on quotas

This library is for applications that legitimately hold several credentials: multi-tenant products where each tenant brings a key, separate dev/staging/prod projects, multi-region Azure OpenAI deployments, paid-to-backup failover chains. Whether a particular set of keys may be pooled is governed by each provider's terms. Nothing here makes a quota larger.

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 51 8/25/2026