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; embeddingstext-embedding-3-small(EmbeddingModelcan be changed). - Bearer authentication;
POST chat/completionswithmax_completion_tokens,response_formatandtemperaturewhen set. - A
refusalfield becomes the content and setsIsRefusal; so doesfinish_reason == "content_filter".finish_reason == "length"setsIsTruncated. EmbedAsynccallsPOST embeddings.SupportsEmbeddingsis true.
AnthropicProvider
- Default model
AnthropicProvider.DefaultModelId=claude-opus-5-5. - Headers
x-api-keyandanthropic-version: 2023-06-01;POST messageswithmax_tokens,system,messagesandoutput_config. - Text blocks are concatenated into the content.
stop_reason == "refusal"setsIsRefusal;"max_tokens"setsIsTruncated. - Temperature is sent only when
SendTemperatureis true (default false), because current Claude models reject sampling parameters. - No embeddings:
SupportsEmbeddingsis false andEmbedAsyncthrowsNotSupportedException.
Choosing providers
- Builder:
UseOpenAI,UseAnthropic,UseProviderfor the primary;UseOpenAIAsFallback,UseAnthropicAsFallback,UseFallbackProviderfor the fallback. UseProvidersFromEnvironmentreadsANTHROPIC_API_KEYandOPENAI_API_KEY. Anthropic becomes primary when present, OpenAI becomes primary or fallback.- DI:
IntentSharpSettings.PrimaryProvideris"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 throwsInvalidOperationException("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.Providerpicks a configured provider by name and disables fallback. - Names must differ. The client keys providers by
IAIProvider.Name, ignoring case, andIntentClientBuilderrefuses a primary and a fallback with the same name withArgumentException. The built-in names are fixed (OpenAI,Anthropic), soUseOpenAIwithUseOpenAIAsFallback, orUseAnthropicwithUseAnthropicAsFallback, 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;UseChatClientandUseChatClientAsFallbacktake anamefor this. - The client owns its providers.
IntentClient.Dispose(andDisposeAsync) disposes the primary and the fallback when they implementIDisposable, providers passed toUseProviderorUseFallbackProviderincluded, so one provider instance must not be shared between clients: disposing one client would dispose anHttpClientthe other still uses. UnderAddIntentSharpthe container disposes the singleton client, and with it its providers, when it is disposed.
Fallback rules are in request-pipeline.md.
Adding a provider
- For an HTTP API, derive from
HttpProviderBase. ImplementName,SupportsEmbeddings,Authenticate,CompleteAsyncandEmbedAsync. CallPostJsonAsyncto get retries and error mapping, and wrap the reading of the reply incatch (Exception ex) when (IsMalformedReply(ex))that throwsMalformedReply(ex). - Map
ResponseFormat.JsonSchemato the vendor's strict structured output when it has one, andJsonObjectto its JSON mode or to nothing. - Set
IsRefusalandIsTruncatedfrom the vendor's stop reason, and fillUsage. - Throw
NotSupportedExceptionfromEmbedAsyncwhen there is no embeddings endpoint. - Register it with
UseProviderorUseFallbackProvider. Add tests that use a fakeHttpMessageHandler; 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 throwsInvalidOperationExceptionwhen the queue is empty.Enqueueadds more.new ScriptedProvider(request => "...", embedder)computes each reply from the request.Requestsrecords every request so tests can assert on prompts;CallCountcounts them.Namedefaults toScriptedandDefaultModeltoscripted-1. Usage is estimated as characters / 4.- It never sets
IsRefusalorIsTruncated.
See tests/IntentSharp.Tests/TestClient.cs for the standard test setup.