Documentation
Table of Contents

Providers

Code: src/IntentSharp/Providers/, src/IntentSharp/Testing/ScriptedProvider.cs.

The contract

public interface IAIProvider
{
    string Name { get; }            // used in results, rate limiting, cache keys and logs
    string DefaultModel { get; }    // used when AIRequest.Model is null
    bool SupportsEmbeddings { get; }
    Task<AIResponse> CompleteAsync(AIRequest request, CancellationToken cancellationToken = default);
    Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken = default);
}

AIRequest is provider-neutral: SystemPrompt, Messages (AIMessage records), Model, MaxOutputTokens, Temperature, Format (ResponseFormat), JsonSchema, SchemaName and Tags. AIResponse carries Content, Model, Provider, Usage (TokenUsage), StopReason, IsRefusal, IsTruncated, CreatedAt and RawJson. The pipeline relies on IsRefusal and IsTruncated, so a provider must set them.

HttpProviderBase

HttpProviderBase holds the shared HTTP plumbing. Settings come from HttpProviderSettings:

Setting Default
ApiKey, Model required; the constructor throws ArgumentException when empty
BaseAddress provider default
Timeout 120 seconds per HTTP attempt
MaxRetries 3 (so up to 4 HTTP attempts)
InitialBackoff 500 ms

PostJsonAsync posts JSON, authenticates through the abstract Authenticate method, and maps failures:

Status Exception Retried
401, 403 AIAuthenticationException no
429 AIRateLimitException (with Retry-After) yes
400, 422 AIValidationException, HttpStatusCode 400 or 422 no
5xx AIProviderInternalException yes
other AIProviderException no
success, but the body is not JSON (a proxy's HTML page, for example) AIProviderException (IsTransient = true), the status as HttpStatusCode, the parse error as the inner exception no
transport error AIProviderException (IsTransient = true) after retries yes
timeout AITimeoutException no

OpenAIProvider and AnthropicProvider treat a JSON reply that lacks the fields they read (OpenAI's {"choices": []} or an error object, an Anthropic content that is not an array) the same way: the KeyNotFoundException, InvalidOperationException, IndexOutOfRangeException or FormatException from reading it becomes a transient AIProviderException with HttpStatusCode 200 and the original as the inner exception. So an unreadable reply ends the attempt as a Provider issue, or falls back, instead of escaping TransformAsync. The protected helpers IsMalformedReply(ex) and MalformedReply(inner) on HttpProviderBase do this for any subclass.

The exception types are in Providers/ProviderExceptions.cs. AIProviderException carries ProviderName, HttpStatusCode and IsTransient; the subtypes set the status and IsTransient for their case (AIRateLimitException and AIProviderInternalException are transient; AIValidationException takes the status, 400 by default). Every provider exception type, AITimeoutException included, also has a constructor that takes an inner exception, so a provider that translates another library's failure (as ChatClientProvider does, extensions-ai.md) keeps the original for logs and debugging.

Backoff is InitialBackoff * 2^(attempt-1) with 0.85x to 1.15x jitter, capped at 30 seconds. A Retry-After on a 429 is used as-is when it is between zero and two minutes. When the builder supplies no HttpClient, the provider creates one, sets its timeout to infinite (the per-request timeout uses a linked token) and disposes it with the provider.

OpenAIProvider

  • Default model OpenAIProvider.DefaultModelId = gpt-4.1; embeddings text-embedding-3-small (EmbeddingModel can be changed).
  • Bearer authentication; POST chat/completions with max_completion_tokens, response_format and temperature when set.
  • A refusal field becomes the content and sets IsRefusal; so does finish_reason == "content_filter". finish_reason == "length" sets IsTruncated.
  • EmbedAsync calls POST embeddings. SupportsEmbeddings is true.

AnthropicProvider

  • Default model AnthropicProvider.DefaultModelId = claude-opus-5-5.
  • Headers x-api-key and anthropic-version: 2023-06-01; POST messages with max_tokens, system, messages and output_config.
  • Text blocks are concatenated into the content. stop_reason == "refusal" sets IsRefusal; "max_tokens" sets IsTruncated.
  • Temperature is sent only when SendTemperature is true (default false), because current Claude models reject sampling parameters.
  • No embeddings: SupportsEmbeddings is false and EmbedAsync throws NotSupportedException.

Choosing providers

  • Builder: UseOpenAI, UseAnthropic, UseProvider for the primary; UseOpenAIAsFallback, UseAnthropicAsFallback, UseFallbackProvider for the fallback.
  • UseProvidersFromEnvironment reads ANTHROPIC_API_KEY and OPENAI_API_KEY. Anthropic becomes primary when present, OpenAI becomes primary or fallback.
  • DI: IntentSharpSettings.PrimaryProvider is "OpenAI" or "Anthropic", ignoring case; the default is Anthropic when both keys exist. The other configured provider becomes the fallback. Any other value is a configuration error: building the client throws InvalidOperationException ("PrimaryProvider "Gemini" is not a known provider. Use "OpenAI" or "Anthropic", or leave it unset."), so a misspelt name never selects a provider silently.
  • Per request: AIRequestOptions.Provider picks a configured provider by name and disables fallback.
  • Names must differ. The client keys providers by IAIProvider.Name, ignoring case, and IntentClientBuilder refuses a primary and a fallback with the same name with ArgumentException. The built-in names are fixed (OpenAI, Anthropic), so UseOpenAI with UseOpenAIAsFallback, or UseAnthropic with UseAnthropicAsFallback, is refused. These four helpers check the name before they create the provider, so a refused call creates no provider and asks the HTTP client factory for nothing. Two chat clients for one vendor need distinct names; UseChatClient and UseChatClientAsFallback take a name for this.
  • The client owns its providers. IntentClient.Dispose (and DisposeAsync) disposes the primary and the fallback when they implement IDisposable, providers passed to UseProvider or UseFallbackProvider included, so one provider instance must not be shared between clients: disposing one client would dispose an HttpClient the other still uses. Under AddIntentSharp the container disposes the singleton client, and with it its providers, when it is disposed.

Fallback rules are in request-pipeline.md.

Adding a provider

  1. For an HTTP API, derive from HttpProviderBase. Implement Name, SupportsEmbeddings, Authenticate, CompleteAsync and EmbedAsync. Call PostJsonAsync to get retries and error mapping, and wrap the reading of the reply in catch (Exception ex) when (IsMalformedReply(ex)) that throws MalformedReply(ex).
  2. Map ResponseFormat.JsonSchema to the vendor's strict structured output when it has one, and JsonObject to its JSON mode or to nothing.
  3. Set IsRefusal and IsTruncated from the vendor's stop reason, and fill Usage.
  4. Throw NotSupportedException from EmbedAsync when there is no embeddings endpoint.
  5. Register it with UseProvider or UseFallbackProvider. Add tests that use a fake HttpMessageHandler; tests never call a real model.

ScriptedProvider for tests

IntentSharp.Testing.ScriptedProvider implements IAIProvider in process:

  • new ScriptedProvider("reply 1", "reply 2") returns queued replies in order and throws InvalidOperationException when the queue is empty. Enqueue adds more.
  • new ScriptedProvider(request => "...", embedder) computes each reply from the request.
  • Requests records every request so tests can assert on prompts; CallCount counts them.
  • Name defaults to Scripted and DefaultModel to scripted-1. Usage is estimated as characters / 4.
  • It never sets IsRefusal or IsTruncated.

See tests/IntentSharp.Tests/TestClient.cs for the standard test setup.