Documentation
Table of Contents

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.FallbackOnTransientErrors is 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 Provider issue.
  • The built-in providers send one request per attempt and do not stream.
  • Embeddings come from the first provider that supports them. IntentClient.EmbedAsync returns null when none does, and Anthropic has none.