Multi-provider AI SDK for ASP.NET Core. One interface with adapters for OpenAI, Anthropic, Google Gemini, Ollama, and Azure OpenAI.
PolyAI.DotNet integrates its providers with ASP.NET Core dependency injection and supports streaming, typed structured output, and attribute-based tool/function calling. It does not depend on Semantic Kernel.
| Provider | Chat | Streaming | Structured output | Tool calling |
|---|---|---|---|---|
| OpenAI (GPT-4o, GPT-4o mini, …) | ✅ | ✅ | ✅ | ✅ |
| Anthropic Claude (3.5 Sonnet, Haiku, …) | ✅ | ✅ | ✅ | ✅ |
| Google Gemini (1.5 Flash, 1.5 Pro, …) | ✅ | ✅ | ✅ | ✅ |
| Ollama (local — Llama 3.2, Mistral, …) | ✅ | ✅ | ✅ | — |
| Azure OpenAI | ✅ | ✅ | ✅ | ✅ |
PolyAI.DotNet is not currently published on NuGet. Build a package from this checkout, then add it from the local output directory:
dotnet pack src/PolyAI/PolyAI.csproj -c Release -o ./artifacts
dotnet new web -o PolyAIDemo
cd PolyAIDemo
dotnet add package PolyAI.DotNet --source ../artifacts
dotnet build// Program.cs (ASP.NET Core / Generic Host)
using PolyAI.Extensions;
builder.Services.AddPolyAI(o => o
.UseAnthropic(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!)
.UseOpenAI(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!)
.UseOllama() // no key required for local Ollama
.UseGemini(Environment.GetEnvironmentVariable("GEMINI_API_KEY")!)
.WithDefaultProvider("anthropic"));public class MyService(IPolyAIClient client)
{
public async Task<string> AskAsync(string question)
{
var response = await client.ChatAsync([
ChatMessage.System("You are a concise assistant."),
ChatMessage.User(question),
]);
Console.WriteLine($"Tokens used: {response.Usage?.TotalTokens}");
return response.Content;
}
}await foreach (var chunk in client.StreamAsync([ChatMessage.User("Tell me a joke.")]))
Console.Write(chunk); // each chunk is a string, stream to the browser as SSErecord MovieReview(string Title, int Score, string Summary);
var review = await client.StructuredAsync<MovieReview>([
ChatMessage.User("Review Inception in JSON."),
]);
Console.WriteLine($"{review.Title}: {review.Score}/10 — {review.Summary}");using PolyAI.Tools;
public class WeatherTools
{
[PolyAITool("Get current weather for a location")]
public string GetWeather(
[PolyAIParam("City name")] string city,
[PolyAIParam("Unit: celsius or fahrenheit")] string unit = "celsius")
=> $"22°{(unit == "fahrenheit" ? "F" : "C")} in {city}, sunny";
}
// Discover tools via reflection
var tools = ToolRegistry.FromInstance(new WeatherTools());
var response = await client.ChatAsync(
[ChatMessage.User("What's the weather in Tokyo?")],
new ChatOptions { Tools = tools });
foreach (var call in response.ToolCalls)
Console.WriteLine($"Model called: {call.Name}({call.ArgumentsJson})");FromInstance uses the object's runtime type, so a tool object resolved from DI behind an
interface or base type (ITools tools = sp.GetRequiredService<ITools>()) is discovered normally.
Each parameter is described to the model as JSON Schema:
| C# type | Schema |
|---|---|
string, Guid, DateOnly, TimeOnly |
string |
DateTime, DateTimeOffset |
string with format: "date-time" |
byte … ulong |
integer |
float, double, decimal |
number |
bool |
boolean |
any enum |
string with the member names as enum |
T[], List<T>, any IEnumerable<T> |
array with a nested items schema |
T? |
the schema of T, and not required |
CancellationToken |
omitted — supplied by your dispatch code, never by the model |
Any other type — a DTO, a dictionary, a multi-dimensional array — throws PolyAIException at
discovery time, naming the tool and the parameter. A schema that silently mislabels a parameter is
worse than a startup error: the model emits "a, b" where the tool expects ["a","b"], and the
mismatch surfaces much later as an unexplained binding failure. Accept a JSON string and
deserialize it inside the tool if you need a richer shape.
Only date-time is emitted as a string format. Gemini rejects every other one ("only 'enum' and
'date-time' are supported for STRING type"), so a wider set would turn a valid tool into a failed
request on one of the five providers.
var router = serviceProvider.GetRequiredService<IPolyAIRouter>();
// Use OpenAI for this request regardless of the default
var openaiClient = router.GetProvider("openai");
var response = await openaiClient.ChatAsync([ChatMessage.User("Hi")],
new ChatOptions { Model = "gpt-4o" });PolyAI.DotNet
├── Abstractions/
│ ├── IPolyAIClient ← the core interface (chat / stream / structured)
│ ├── IPolyAIRouter ← routes requests to a named provider
│ ├── ChatMessage ← System / User / Assistant / Tool roles
│ ├── ChatOptions ← temperature, top_p, max_tokens, stop, tools
│ ├── ChatResponse ← content, tool calls, token usage, finish reason
│ └── TokenUsage
├── Providers/
│ ├── OpenAI/ ← OpenAIProvider (OpenAIOptions)
│ ├── Anthropic/ ← AnthropicProvider (AnthropicOptions)
│ ├── Gemini/ ← GeminiProvider (GeminiOptions)
│ ├── Ollama/ ← OllamaProvider (OllamaOptions)
│ └── Azure/ ← AzureOpenAIProvider (AzureOpenAIOptions)
├── Tools/
│ ├── [PolyAITool] ← mark a method as a callable tool
│ ├── [PolyAIParam] ← describe a parameter
│ ├── JsonSchemaNode ← parameter schema: type, format, enum, array items
│ └── ToolRegistry ← reflection-based discovery
├── Errors/
│ ├── PolyAIException
│ ├── ProviderException ← status code, raw body
│ ├── ProviderAuthException ← 401/403
│ └── ProviderRateLimitException ← 429, RetryAfter
└── Extensions/
├── ServiceCollectionExtensions ← .AddPolyAI(...)
└── PolyAIBuilder ← .UseOpenAI / .UseAnthropic / ...
Extension points:
- Add a new provider: implement
ProviderBase, register viaPolyAIBuilder. Parse the response body throughProviderBase.ReadChatResponseAsync— it is the boundary that keeps the error contract below true for your provider too. - Custom router: implement
IPolyAIRouterand register in DI before callingBuild(). - Custom
ChatOptions:ChatOptionsis asealed classwith nullable properties — extend by subclassing if you need provider-specific extras.
Every failure this library raises derives from PolyAIException, so one catch block is enough:
try
{
var response = await client.ChatAsync([ChatMessage.User("Hi")]);
}
catch (ProviderRateLimitException ex) // 429 — ex.RetryAfter is the provider's backoff hint
{
await Task.Delay(ex.RetryAfter ?? TimeSpan.FromSeconds(5));
}
catch (ProviderAuthException ex) // 401/403 — the credential is wrong or expired
{
logger.LogError("Check the {Provider} API key", ex.Provider);
}
catch (PolyAIException ex) // everything else, including malformed responses
{
logger.LogError(ex, "Call failed");
}That guarantee covers the response body as well as the status code. A provider body is untrusted
input — a proxy can truncate it, a gateway can return an empty 200, and a provider can change a
field's shape — so a body that cannot be read raises a ProviderException carrying
Provider, StatusCode, and the raw ResponseBody for diagnosis, with the underlying parse
failure preserved as InnerException. It never surfaces as a raw System.Text.Json exception.
All options classes follow the same pattern: pass the API key to Use<Provider>(), then optionally pass a configure action for advanced settings.
services.AddPolyAI(o => o
.UseOpenAI("key", opts =>
{
opts.DefaultModel = "gpt-4o";
opts.BaseUrl = "https://my-custom-proxy/v1"; // OpenAI-compatible endpoint
})
.UseAnthropic("sk-ant-...", opts => opts.DefaultModel = "claude-3-5-sonnet-20241022")
.UseOllama(opts => opts.BaseUrl = "http://my-ollama-host:11434"));git clone https://github.com/amasen02/polyai-dotnet.git
cd polyai-dotnet
# Build and test
dotnet build
dotnet test
# Run the sample (set at least one API key)
export ANTHROPIC_API_KEY=sk-ant-...
dotnet run --project samples/PolyAI.Sample# Build
docker build -f samples/PolyAI.Sample/Dockerfile -t polyai-sample .
# Run
docker run -e ANTHROPIC_API_KEY=sk-ant-... polyai-sample
# Or with docker compose
ANTHROPIC_API_KEY=sk-ant-... docker compose up135 xUnit tests covering all providers, the DI wiring, error types, streaming, tool discovery and schema generation, structured output deserialization, and the outgoing request wire format. All tests use fake HTTP handlers — no live API calls, no environment variables required.
FakeHttpMessageHandler serves canned responses; CapturingHandler (in QaProbes/) additionally records the outgoing URI, headers, and body, so a provider that builds a malformed request URL fails a test instead of failing in production.
dotnet test- License: MIT, permanently. No relicensing.
- No CLA: all contributors retain ownership of their contributions.
- Honest history: no backdated commits, no fabricated activity.
- Security: vulnerabilities acknowledged within 48 hours. See SECURITY.md.
- Code of Conduct: Contributor Covenant 2.1.
- Reproducible CI: green build required before any merge.
Ama Senevirathne — github.com/amasen02
Also see: freshcart-backend — a full .NET 10 Aspire microservices e-commerce platform.
We deliberately built this repository to be 100% open, modular, and easy to fork and extend:
- 🔓 Permissive MIT License: Zero CLA, commercial use permitted, you keep full ownership of your contributions.
- 🛡️ Hardened Supply Chain: Built with automated CI testing, OpenSSF Scorecard supply-chain security, and strict quality checks.
- ⚡ High-Performance Foundation: Zero unnecessary bloat — clean architectural boundaries that make hacking on this code a joy.
- Add adapters for DeepSeek, Groq, Mistral, and Cohere providers
- Implement distributed semantic caching provider using Redis / HybridCache in .NET 9
- Add built-in rate limiter and exponential fallback router across AI model tiers
- Implement ASP.NET Core middleware for automatic token metering and cost tracing
git clone https://github.com/amasen02/polyai-dotnet.git
cd polyai-dotnet
dotnet test- Fork the repo & clone it locally.
- Create your feature branch (
git checkout -b feat/my-awesome-idea). - Verify tests pass cleanly.
- Open a PR — we review and merge PRs within 24–48 hours!
⭐ Found PolyAI-DotNet useful? Please star the repository to support its development!