Skip to content

Latest commit

 

History

History
328 lines (251 loc) · 12.6 KB

File metadata and controls

328 lines (251 loc) · 12.6 KB

PolyAI.DotNet

OpenSSF Scorecard Security Policy

CI License: MIT PRs Welcome Contributor Covenant

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.

Supported providers

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 ✅ ✅ ✅ ✅

Quickstart

Build from source and use a local package

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

Register

// 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"));

Chat

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;
    }
}

Streaming

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 SSE

Structured output

record 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}");

Tool / function calling

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.

Parameter types

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.

Route to a specific provider

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" });

Architecture

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 via PolyAIBuilder. Parse the response body through ProviderBase.ReadChatResponseAsync — it is the boundary that keeps the error contract below true for your provider too.
  • Custom router: implement IPolyAIRouter and register in DI before calling Build().
  • Custom ChatOptions: ChatOptions is a sealed class with nullable properties — extend by subclassing if you need provider-specific extras.

Error handling

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.

Configuration

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"));

Running locally

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

Docker

# 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 up

Tests

135 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

Open source commitments

  • 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.

Author

Ama Senevirathne — github.com/amasen02

Also see: freshcart-backend — a full .NET 10 Aspire microservices e-commerce platform.


🌟 Fork, Build Upon & Extend This Project

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.

💡 High-Impact Ideas Ready for You to Build:

  • 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

🚀 60-Second Quickstart

git clone https://github.com/amasen02/polyai-dotnet.git
cd polyai-dotnet
dotnet test

🤝 Frictionless Contributions

  1. Fork the repo & clone it locally.
  2. Create your feature branch (git checkout -b feat/my-awesome-idea).
  3. Verify tests pass cleanly.
  4. Open a PR — we review and merge PRs within 24–48 hours!

📈 Stargazers Over Time

Star History Chart

⭐ Found PolyAI-DotNet useful? Please star the repository to support its development!