Runtime Credentials¶
AIProviderConnect resolves provider configuration (API key, base URL, model) at
DI-registration time and bakes it into the singleton IAIProvider. This works
for applications with a static key per provider, but not for multi-user
scenarios where each caller supplies their own credentials.
Per-request credentials let a consumer supply API key, base URL, or model for a single call — or for every call on a transient provider — without rebuilding the DI graph.
RequestCredentials¶
namespace AIProviderConnect.Models;
public sealed record RequestCredentials
{
public string? ApiKey { get; init; }
public string? BaseUrl { get; init; }
public string? Model { get; init; }
}
Every field is nullable. A null (or whitespace) value means "use the
provider's configured value". A record with all three unset is treated as
"no override". BaseUrl must be the full API base including the version
segment (for example https://api.example.com/v1/).
The ToString() override masks ApiKey so a credential record can be logged
safely — the key never appears in full.
Priority Chain¶
Credential and model resolution follows a strict priority at each call site:
GetProvider(id, overrides)fixed credentials — highest, attached once to the transient instance before publication.ICredentialResolverresult — consulted once per call if no fixed credentials are set and a resolver is registered.AIProviderOptions(configured at DI registration) — fallback for any field the first two sources leave unset.
For the model specifically (chat path):
credentials?.Model → request.Model → Options.DefaultModel.
An empty effective model throws AiException(InvalidRequest).
For embeddings:
credentials?.Model → request.Model → DefaultEmbeddingModel (never the
chat DefaultModel).
ICredentialResolver¶
namespace AIProviderConnect.Abstractions;
public interface ICredentialResolver
{
ValueTask<RequestCredentials?> ResolveAsync(
string providerId,
CancellationToken cancellationToken = default);
}
The implementation must be a thread-safe singleton — the library calls it
once per provider call without creating a DI scope. The providerId parameter
identifies which provider is being resolved; the resolver reads per-caller
identity from ambient state (e.g. HttpContext, AsyncLocal<T>, or a custom
tenant token).
Sample Implementation¶
public sealed class TenantCredentialResolver(
IServiceScopeFactory scopeFactory,
IHttpContextAccessor httpContext) : ICredentialResolver
{
public async ValueTask<RequestCredentials?> ResolveAsync(
string providerId, CancellationToken ct)
{
var tenantId = httpContext.HttpContext?.User.FindFirst("tenant_id")?.Value;
if (tenantId is null)
return null; // no override — use configured options
// Create a short-lived scope to reach scoped storage.
using var scope = scopeFactory.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<ITenantKeyStore>();
var key = await store.GetKeyAsync(tenantId, providerId, ct);
return key is not null
? new RequestCredentials { ApiKey = key }
: null;
}
}
Registration¶
Wire the resolver through the builder:
services.AddAiProviders(b =>
{
b.UseCredentialResolver(new TenantCredentialResolver(scopeFactory, httpContext));
// ... Add definitions, Configure options, etc.
});
Alternatively, register the resolver directly as a singleton in the container:
services.AddSingleton<ICredentialResolver>(new TenantCredentialResolver(...));
services.AddAiProviders(); // library resolves it via GetService at provider construction
GetProvider with Overrides (Factory Path)¶
var factory = serviceProvider.GetRequiredService<IAIProviderFactory>();
var provider = factory.GetProvider("openai", new RequestCredentials
{
ApiKey = "user-supplied-key",
BaseUrl = "https://user-instance.example.com/v1/",
Model = "gpt-4o-mini"
});
var response = await provider.ChatAsync(request);
GetProvider(id, overrides) returns a transient instance bound to the
supplied credentials. The shared singleton (resolved by GetProvider(id)) is
never mutated. Each call to GetProvider(id, overrides) builds a fresh
provider — safe under concurrency with no cross-contamination.
The transient instance honors overrides.ApiKey, overrides.BaseUrl, and
overrides.Model for every transport the underlying provider type supports
(chat, streaming, model discovery, and embeddings). Cast the result to
IEmbeddingProvider or IStreamingChatProvider to access optional
capabilities.
Known limitation — model-override decoration hides embeddings
When consumer-supplied model overrides are registered for a provider id
(see Consumer-Supplied Overrides),
the transient instance is wrapped in the same decorator GetProvider(id)
applies, and the decorators expose IAIProvider, model discovery, and
streaming only — they do not implement IEmbeddingProvider. A provider id
with model-override entries therefore exposes no embeddings through either
GetProvider overload. This capability loss predates runtime credentials;
the factory overload neither introduces nor widens it.
Shared HttpClient Expectation¶
Providers resolve HttpClient from the DI container. Both the singleton and
transient (factory-built) instances share the same HttpClient. Per-call
credential changes apply at the request-message level (headers, URL) — the
shared client itself is never mutated. This is the expected pattern: register
one HttpClient (or use AddHttpClient()) and let the library build per-request
configuration.
Retry Determinism¶
When retry is enabled (MaxRetryCount > 0), the Polly pipeline retries the
outgoing HTTP call, but credentials are resolved once before the pipeline
is entered. A retry reuses the same resolved RequestCredentials — the
resolver is never re-consulted mid-flight.
Error Behavior¶
| Condition | Result |
|---|---|
ICredentialResolver throws |
AiException(ProviderMissingConfiguration) naming the provider id |
| Caller cancellation during resolution | OperationCanceledException propagates unchanged |
| Fixed credentials set, then overwritten | InvalidOperationException (single-write enforcement) |
Provider does not derive from AIProviderBase |
AiException(ConfigurationError) |