Providers
A provider sends a request to a model and brings the reply back. Every client has one primary provider, and can
have a fallback that is called when the primary fails with an error that may pass. IntentSharp includes providers
for OpenAI and Anthropic. For any other model service, use its IChatClient
(Microsoft.Extensions.AI) or write a provider of your own (below).
OpenAI and Anthropic take an API key
Each builder method takes an API key and, optionally, the model to use when a call names none:
var client = new IntentClientBuilder()
.UseAnthropic(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!)
.UseOpenAIAsFallback(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!)
.Build();
| Method | Adds |
|---|---|
UseOpenAI(apiKey, model) |
OpenAI as the primary, named OpenAI |
UseAnthropic(apiKey, model) |
Anthropic as the primary, named Anthropic |
UseOpenAIAsFallback(apiKey, model) |
OpenAI as the fallback |
UseAnthropicAsFallback(apiKey, model) |
Anthropic as the fallback |
UseProvidersFromEnvironment() |
the providers whose keys are in ANTHROPIC_API_KEY and OPENAI_API_KEY |
Without a model, OpenAI uses gpt-4.1 (OpenAIProvider.DefaultModelId) and Anthropic uses claude-opus-5-5
(AnthropicProvider.DefaultModelId).
UseProvidersFromEnvironment makes Anthropic the primary when ANTHROPIC_API_KEY is set. OpenAI then becomes the
fallback, or the primary when there is no Anthropic key. With neither key set and no primary already added, it
throws InvalidOperationException. The quick start uses it.
Calling a primary method again replaces the primary, and the same goes for the fallback. Build() throws
InvalidOperationException when no primary is set.
Each provider retries its own HTTP errors
Before a failure reaches your call, the OpenAI and Anthropic providers retry the errors that often pass. To change
how, create the provider with HttpProviderSettings and add it with UseProvider:
var openAI = new OpenAIProvider(new HttpProviderSettings
{
ApiKey = apiKey,
Model = OpenAIProvider.DefaultModelId,
BaseAddress = new Uri("https://llm-gateway.example.com/v1/"),
Timeout = TimeSpan.FromSeconds(60),
MaxRetries = 2,
InitialBackoff = TimeSpan.FromSeconds(1),
}, logger: logger);
var client = new IntentClientBuilder()
.UseProvider(openAI)
.Build();
| Setting | Default | What it does |
|---|---|---|
ApiKey, Model |
none | Required. The constructor throws ArgumentException when either is blank. |
BaseAddress |
the vendor's public endpoint | Another address, for example a gateway that accepts the same requests. End it with a slash. |
Timeout |
120 seconds | The time limit for one HTTP attempt. Each retry gets its own. |
MaxRetries |
3 | Retries after a 429, a 5xx or a transport error, so up to 4 HTTP attempts. |
InitialBackoff |
500 ms | The wait before the first retry. Each later wait doubles, with some jitter, up to 30 seconds. A 429 reply's Retry-After under two minutes is used instead. |
Two more settings belong to one provider each. AnthropicProvider.SendTemperature is false by default, so no
temperature is sent, because current Claude models reject it. OpenAIProvider.EmbeddingModel is
text-embedding-3-small by default; Anthropic has no embeddings.
A provider you create yourself logs each retry at Warning to the logger you pass to its constructor.
WithLoggerFactory on the builder reaches only the providers the builder creates, so call it before UseOpenAI
or UseAnthropic.
Your own HttpClient
WithHttpClientFactory(name => ...) on the builder supplies the HttpClient for each built-in provider, by the
provider's name, OpenAI or Anthropic. A client you supply keeps its own Timeout, 100 seconds unless you
change it, which can end an attempt before the provider's limit does. Set it to Timeout.InfiniteTimeSpan, or
above the provider's Timeout. The provider never disposes a client you supply.
With dependency injection, the settings choose the providers
AddIntentSharp registers the client and builds its providers from the IntentSharp configuration section:
Anthropic is the primary when its key is set, unless PrimaryProvider names OpenAI, and with both keys set the
other provider is the fallback. The provider settings, what happens with an unknown PrimaryProvider, and the
HttpClient each provider gets are in Configuration and DI.
Here OpenAI is the primary, behind a gateway:
services.AddIntentSharp(settings =>
{
settings.PrimaryProvider = "OpenAI";
settings.OpenAIModel = "gpt-4.1";
});
// The HttpClient the OpenAI provider uses: here, an OpenAI-compatible gateway behind a proxy.
services.AddHttpClient(ServiceCollectionExtensions.OpenAIHttpClientName, http =>
http.BaseAddress = new Uri("https://llm-gateway.example.com/v1/"))
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { Proxy = new WebProxy("http://proxy.example.com:8080") });
A provider error ends the call with an issue, not an exception
When a provider still fails after its own retries, the call does not throw. Unless the fallback answers, it
returns a result with Success false and an issue whose Source is Provider, carrying the failure's message:
| The provider's reply | The failure | Retried by the provider | Falls back |
|---|---|---|---|
| 401 or 403 | AIAuthenticationException |
no | no |
| 429 | AIRateLimitException |
yes | yes |
| 400 or 422 | AIValidationException |
no | no |
| 5xx | AIProviderInternalException |
yes | yes |
| any other error status, such as 404 | AIProviderException |
no | no |
| a success whose body is not JSON, or lacks the fields the provider reads | AIProviderException, transient |
no | yes |
| a transport error | AIProviderException, transient |
yes | yes |
no answer within the provider's Timeout |
AITimeoutException |
no | yes |
One more transient failure starts in your own process. A rate limiter on the client (WithRateLimiter) that would
make a model call wait longer than 30 seconds throws AIRateLimitException with the message "Local rate limit for
...", and nothing is sent. The provider does not retry it, and it falls back like a 429.
AIProviderException and the types derived from it carry ProviderName, HttpStatusCode and IsTransient. A
cancellation of your own CancellationToken is not a provider failure: the call throws
OperationCanceledException.
The fallback answers when the primary fails with a transient error
Each model call goes to the primary first. It goes to the fallback only when all of these hold:
- the primary failed with a transient error or a timeout: the rows marked "Falls back" above, or the local rate limit;
- a fallback is set;
- the call did not name a provider (next section);
IntentClientOptions.FallbackOnTransientErrorsis true, as it is by default.
Nothing else falls back: an authentication error, a rejected request and any other status end the call with their
issue. Each fallback logs a Warning. The result's Provider, and each attempt's, name the provider that
answered. The fallback use case shows a primary that fails and a
fallback that answers.
One call can name its provider
Set AIRequestOptions.Provider to a configured provider's name, and Model to the model you want:
var options = new AIRequestOptions { Provider = "OpenAI", Model = "gpt-4.1" };
var summary = await email.AIAsync(client, "Summarize this email in one sentence.", options);
A call that names a provider has no fallback. A name that is not configured throws ArgumentException. An intent
file's provider field names the provider for every run of that intent, and turns fallback off for it. Its model
field sets the model for every run, and does not turn fallback off.
Names identify providers, compared ignoring case, so the primary and the fallback must have different names. The
builder refuses a provider whose name is already in use with ArgumentException, so UseOpenAI with
UseOpenAIAsFallback is refused.
The client owns its providers
Disposing a client disposes its primary and its fallback, including providers you passed to UseProvider or
UseFallbackProvider. So give each client its own provider instances: if two clients shared one, disposing the
first would close the HttpClient the second still uses. Under AddIntentSharp the container disposes the client,
and with it its providers.
Write a provider of your own
A provider implements IAIProvider:
| Member | What it is |
|---|---|
Name |
The name in results, logs, rate limiting and AIRequestOptions.Provider. |
DefaultModel |
The model used when a request names none. |
SupportsEmbeddings |
True when EmbedAsync works. |
CompleteAsync(request, cancellationToken) |
Sends one AIRequest and returns one AIResponse. |
EmbedAsync(text, cancellationToken) |
Returns an embedding vector, or throws NotSupportedException. |
CompleteAsync receives everything the client sends for one attempt: SystemPrompt, Messages, Model (null
for your default), MaxOutputTokens, Temperature (null to send none), Format and, for a typed result,
JsonSchema and SchemaName. When Format is JsonSchema, the reply must match JsonSchema, so send it as your
service's strict structured output if it has one. Tags are for your own logs; never send them to the model.
The reply fills Content, Model, Provider and Usage, and sets two flags the checks rely on: IsTruncated
when the output limit cut the reply off, which fails that attempt, and IsRefusal when the model declined, which
ends the call's attempts.
For an HTTP service, derive from HttpProviderBase, which gives your provider the settings, the retries and the
error table above. Implement Authenticate, post with PostJsonAsync, and read the reply inside
catch (Exception ex) when (IsMalformedReply(ex)) that throws MalformedReply(ex):
/// <summary>
/// A provider for a model service of your own that takes {model, system, messages, max_tokens, schema} at v1/complete
/// and answers {text, stop, input_tokens, output_tokens}.
/// </summary>
public sealed class InHouseModelProvider : HttpProviderBase
{
public InHouseModelProvider(HttpProviderSettings settings, HttpClient? httpClient = null, ILogger? logger = null)
: base(settings, httpClient, new Uri("https://models.internal.example.com/"), logger) { }
public override string Name => "InHouse";
public override bool SupportsEmbeddings => false;
protected override void Authenticate(HttpRequestMessage message)
=> message.Headers.Add("X-Api-Key", Settings.ApiKey);
public override async Task<AIResponse> CompleteAsync(AIRequest request, CancellationToken cancellationToken = default)
{
var body = new
{
model = request.Model ?? DefaultModel,
system = request.SystemPrompt,
messages = request.Messages.Select(m => new { role = m.Role, content = m.Content }),
max_tokens = request.MaxOutputTokens,
schema = request.Format == ResponseFormat.JsonSchema ? request.JsonSchema : null,
};
// Retries 429, 5xx and transport errors, and turns every failure into an IntentSharp provider exception.
using var reply = await PostJsonAsync("v1/complete", body, cancellationToken);
try
{
var root = reply.RootElement;
var stop = root.GetProperty("stop").GetString();
return new AIResponse
{
Content = root.GetProperty("text").GetString() ?? "",
Model = request.Model ?? DefaultModel,
Provider = Name,
Usage = new TokenUsage(root.GetProperty("input_tokens").GetInt32(), root.GetProperty("output_tokens").GetInt32()),
StopReason = stop,
IsRefusal = stop == "refused", // ends the call's attempts
IsTruncated = stop == "length", // fails this attempt
};
}
catch (Exception ex) when (IsMalformedReply(ex))
{
// A reply without these fields fails the attempt, or falls back, instead of escaping the call.
throw MalformedReply(ex);
}
}
public override Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("The in-house model service has no embeddings.");
}
Add it as you would a built-in provider, as the primary or the fallback, beside OpenAI or Anthropic or on its own:
var client = new IntentClientBuilder()
.UseProvider(new InHouseModelProvider(new HttpProviderSettings { ApiKey = apiKey, Model = "house-large" }))
.UseAnthropicAsFallback(anthropicKey)
.Build();
A provider that does not derive from HttpProviderBase throws the same exception types for its failures:
AIProviderException (with isTransient true for a failure that may pass), AIRateLimitException,
AIAuthenticationException, AIValidationException, AIProviderInternalException or AITimeoutException. Those
end the call with a Provider issue, or fall back. Any other exception from your provider reaches your code as an
exception.
A provider that wraps another one to log every request is in Seeing every request. To
test without a model, use ScriptedProvider (Testing).
Limits
- One fallback per client: a client has at most two providers, a primary and a fallback.
- A model name set on a call or in an intent file also goes to the fallback, so set one only when both providers
accept it. A fallback from another vendor rejects a model name it does not know with a 400, and the call ends
with that
Providerissue. - The built-in providers send one request per attempt and do not stream.
- Embeddings come from the first provider that supports them.
IntentClient.EmbedAsyncreturns null when none does, and Anthropic has none.