Does IChatClient make every model interchangeable?
No. It standardizes the application contract, not model behavior. Capability metadata remains necessary for streaming, tools, usage reporting, context limits, and provider-managed conversations.
Build one provider-neutral gateway for OpenAI-compatible services, Azure OpenAI, and local Ollama models—then switch models through configuration without rewriting your application layer.
Build one ASP.NET Core gateway that can talk to OpenAI-compatible services, Azure OpenAI, or a local Ollama model without leaking provider SDK types into endpoints or business logic.
The finished API exposes a stable contract:
POST /api/chat — complete responsePOST /api/chat/stream — Server-Sent Events streamGET /api/providers — enabled providers and declared capabilitiesGET /health — gateway healthHTTP endpoint
│
▼
IAiGateway
│
▼
Provider registry ──► descriptor (identity + model + capabilities + IChatClient)
│
├── OpenAI-compatible client
├── Azure OpenAI client
└── OllamaSharp clientDirectory.Packages.props, not scattered through the prose. Last reviewed: July 22, 2026.A prototype often begins with a provider SDK directly inside an endpoint. That works until the provider, deployment, model, authentication mechanism, or test environment changes.
app.MapPost("/api/chat", async (ChatRequest request) =>
{
var client = new OpenAI.Chat.ChatClient(
model: builder.Configuration["OpenAI:Model"]!,
apiKey: builder.Configuration["OpenAI:ApiKey"]!);
var completion = await client.CompleteChatAsync(request.Message);
return Results.Ok(completion.Value.Content[0].Text);
});The endpoint now owns provider construction, credentials, request conversion, and response mapping. Testing it requires a real network. A provider change becomes an endpoint rewrite.
llama3.2 or another chat model pulleddotnet --version
ollama --version
ollama pull llama3.2
ollama listmkdir SwitchableAiGateway
cd SwitchableAiGateway
dotnet new sln -n SwitchableAiGateway
dotnet new web -n SwitchableAiGateway.Api -o src/SwitchableAiGateway.Api
dotnet new classlib -n SwitchableAiGateway.Core -o src/SwitchableAiGateway.Core
dotnet new xunit -n SwitchableAiGateway.Tests -o tests/SwitchableAiGateway.Tests
dotnet sln add src/SwitchableAiGateway.Api
dotnet sln add src/SwitchableAiGateway.Core
dotnet sln add tests/SwitchableAiGateway.Tests
dotnet add src/SwitchableAiGateway.Api reference src/SwitchableAiGateway.Core
dotnet add tests/SwitchableAiGateway.Tests reference src/SwitchableAiGateway.CoreEnable Central Package Management so fast-moving package versions live in one reviewable file.
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Pin versions verified on your publication date. -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.x.y" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.x.y" />
<PackageVersion Include="OpenAI" Version="2.x.y" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.x.y" />
<PackageVersion Include="Azure.Identity" Version="1.x.y" />
<PackageVersion Include="OllamaSharp" Version="5.x.y" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.x.y" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.x.y" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.x.y" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.x.y" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.x.y" />
</ItemGroup>
</Project>x.y placeholder with the stable version restored and tested on the publication date. Keeping placeholders here is deliberate: provider adapters change faster than the tutorial’s architectural lessons.<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="OpenAI" />
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="OllamaSharp" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
</Project>IChatClient is the provider-neutral application contract. Its current methods are GetResponseAsync and GetStreamingResponseAsync.
using Microsoft.Extensions.AI;
public interface IAiGateway
{
Task<GatewayResult> SendAsync(
GatewayChatRequest request,
CancellationToken cancellationToken);
IAsyncEnumerable<GatewayStreamEvent> StreamAsync(
GatewayChatRequest request,
CancellationToken cancellationToken);
}CompleteAsync, CompleteStreamingAsync, or .AsChatClient() predates the current API. Use GetResponseAsync, GetStreamingResponseAsync, and .AsIChatClient().Do not name the public DTO ChatResponse; that name already belongs to Microsoft.Extensions.AI.ChatResponse.
public sealed record GatewayChatRequest(
string Message,
string? ConversationId = null,
string? Provider = null,
bool EnableTools = false);
public sealed record GatewayChatResponse(
string ConversationId,
string Provider,
string Model,
string Content,
string? FinishReason,
TokenUsageDto? Usage,
long DurationMs);
public sealed record TokenUsageDto(long? InputTokens, long? OutputTokens);{
"AiGateway": {
"DefaultProvider": "ollama",
"AllowRequestProviderSelection": true,
"MaximumConversationMessages": 20,
"MaximumConversationCharacters": 40000,
"ConversationLifetimeMinutes": 30,
"RequestTimeoutSeconds": 60
},
"AiProviders": {
"OpenAI": { "Enabled": false, "Model": "configured-model-name" },
"AzureOpenAI": {
"Enabled": false,
"Endpoint": "https://example.openai.azure.com/",
"Deployment": "configured-deployment-name"
},
"Ollama": {
"Enabled": true,
"Endpoint": "http://localhost:11434",
"Model": "llama3.2"
}
}
}using System.ComponentModel.DataAnnotations;
public sealed class AiGatewayOptions
{
public const string SectionName = "AiGateway";
[Required] public string DefaultProvider { get; init; } = "ollama";
public bool AllowRequestProviderSelection { get; init; }
[Range(2, 100)] public int MaximumConversationMessages { get; init; } = 20;
[Range(1000, 200000)] public int MaximumConversationCharacters { get; init; } = 40000;
[Range(1, 1440)] public int ConversationLifetimeMinutes { get; init; } = 30;
[Range(5, 600)] public int RequestTimeoutSeconds { get; init; } = 60;
}builder.Services
.AddOptions<AiGatewayOptions>()
.Bind(builder.Configuration.GetSection(AiGatewayOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();Store API keys with User Secrets locally. Never put credentials in appsettings.json.
cd src/SwitchableAiGateway.Api
dotnet user-secrets init
dotnet user-secrets set "AiProviders:OpenAI:ApiKey" "YOUR_KEY"using Microsoft.Extensions.AI;
public sealed record AiProviderCapabilities(
bool SupportsStreaming,
bool SupportsTools,
bool ReportsUsage,
int? MaximumContextTokens,
bool SupportsProviderManagedConversation);
public sealed record AiProviderDescriptor(
string Name,
string Model,
AiProviderCapabilities Capabilities,
IChatClient Client);| Capability | Why declare it |
|---|---|
| Streaming | Reject streaming requests before invoking an unsupported adapter. |
| Tools | Prevent tools from being offered to deployments that cannot call them reliably. |
| Usage reporting | Do not promise token counts when the provider does not return them. |
| Context limit | Use it as an operator-supplied guardrail, not a runtime guess. |
| Provider conversation | Controls whether a returned conversation identifier can be reused. |
API-key authentication, model name, optional compatible endpoint.
Endpoint plus deployment name, preferably Microsoft Entra authentication.
Local inference through an IChatClient-compatible client.
public static class AiProviderNames
{
public const string OpenAi = "openai";
public const string AzureOpenAi = "azure-openai";
public const string Ollama = "ollama";
}using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using OllamaSharp;
using OpenAI;
using System.ClientModel;
// OpenAI-compatible cloud client
builder.Services
.AddKeyedChatClient(AiProviderNames.OpenAi, sp =>
{
IConfiguration config = sp.GetRequiredService<IConfiguration>();
string model = config["AiProviders:OpenAI:Model"]
?? throw new InvalidOperationException("OpenAI model is missing.");
string apiKey = config["AiProviders:OpenAI:ApiKey"]
?? throw new InvalidOperationException("OpenAI API key is missing.");
return new OpenAI.Chat.ChatClient(model, apiKey).AsIChatClient();
})
.UseFunctionInvocation()
.UseOpenTelemetry(configure: telemetry =>
telemetry.EnableSensitiveData = builder.Environment.IsDevelopment());
// Azure OpenAI with Microsoft Entra ID / managed identity
builder.Services
.AddKeyedChatClient(AiProviderNames.AzureOpenAi, sp =>
{
IConfiguration config = sp.GetRequiredService<IConfiguration>();
var endpoint = new Uri(config["AiProviders:AzureOpenAI:Endpoint"]!);
string deployment = config["AiProviders:AzureOpenAI:Deployment"]!;
return new AzureOpenAIClient(endpoint, new DefaultAzureCredential())
.GetChatClient(deployment)
.AsIChatClient();
})
.UseFunctionInvocation()
.UseOpenTelemetry(configure: telemetry =>
telemetry.EnableSensitiveData = builder.Environment.IsDevelopment());
// Local Ollama. Microsoft.Extensions.AI.Ollama is deprecated; use OllamaSharp.
builder.Services
.AddKeyedChatClient(AiProviderNames.Ollama, sp =>
{
IConfiguration config = sp.GetRequiredService<IConfiguration>();
var endpoint = new Uri(config["AiProviders:Ollama:Endpoint"]!);
string model = config["AiProviders:Ollama:Model"]!;
return new OllamaApiClient(endpoint, model);
})
.UseFunctionInvocation()
.UseOpenTelemetry(configure: telemetry =>
telemetry.EnableSensitiveData = builder.Environment.IsDevelopment());ApiKeyCredential can be used locally. Prefer DefaultAzureCredential for deployed workloads.public interface IAiProviderRegistry
{
AiProviderDescriptor GetRequired(string? requestedProvider);
IReadOnlyCollection<AiProviderDescriptor> GetEnabledProviders();
}using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
public sealed class AiProviderRegistry : IAiProviderRegistry
{
private readonly IReadOnlyDictionary<string, AiProviderDescriptor> _providers;
private readonly AiGatewayOptions _options;
public AiProviderRegistry(IServiceProvider services, IOptions<AiGatewayOptions> options)
{
_options = options.Value;
_providers = new Dictionary<string, AiProviderDescriptor>(StringComparer.OrdinalIgnoreCase)
{
[AiProviderNames.Ollama] = new(
AiProviderNames.Ollama,
"llama3.2",
new(true, false, false, null, false),
services.GetRequiredKeyedService<IChatClient>(AiProviderNames.Ollama)),
[AiProviderNames.OpenAi] = new(
AiProviderNames.OpenAi,
"configured-model-name",
new(true, true, true, null, false),
services.GetRequiredKeyedService<IChatClient>(AiProviderNames.OpenAi)),
[AiProviderNames.AzureOpenAi] = new(
AiProviderNames.AzureOpenAi,
"configured-deployment-name",
new(true, true, true, null, false),
services.GetRequiredKeyedService<IChatClient>(AiProviderNames.AzureOpenAi))
};
}
public AiProviderDescriptor GetRequired(string? requestedProvider)
{
string name = requestedProvider ?? _options.DefaultProvider;
if (!_options.AllowRequestProviderSelection && requestedProvider is not null &&
!name.Equals(_options.DefaultProvider, StringComparison.OrdinalIgnoreCase))
{
throw new ProviderSelectionDisabledException();
}
return _providers.TryGetValue(name, out AiProviderDescriptor? descriptor)
? descriptor
: throw new UnsupportedAiProviderException(name);
}
public IReadOnlyCollection<AiProviderDescriptor> GetEnabledProviders() =>
_providers.Values.ToArray();
}The portable mode stores framework ChatMessage instances in the gateway and sends a bounded history on every request.
using Microsoft.Extensions.AI;
public interface IConversationStore
{
ValueTask<ConversationSnapshot> GetOrCreateAsync(
string? conversationId,
string provider,
CancellationToken cancellationToken);
ValueTask SaveAsync(
string conversationId,
string provider,
IReadOnlyList<ChatMessage> messages,
string? providerConversationId,
CancellationToken cancellationToken);
}
public sealed record ConversationSnapshot(
string ConversationId,
string Provider,
IReadOnlyList<ChatMessage> Messages,
string? ProviderConversationId);using System.Collections.Concurrent;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
public sealed class InMemoryConversationStore : IConversationStore
{
private sealed record Entry(
string Provider,
IReadOnlyList<ChatMessage> Messages,
string? ProviderConversationId,
DateTimeOffset ExpiresAt);
private readonly ConcurrentDictionary<string, Entry> _entries = new();
private readonly AiGatewayOptions _options;
private readonly TimeProvider _timeProvider;
public InMemoryConversationStore(
IOptions<AiGatewayOptions> options,
TimeProvider timeProvider)
{
_options = options.Value;
_timeProvider = timeProvider;
}
public ValueTask<ConversationSnapshot> GetOrCreateAsync(
string? conversationId,
string provider,
CancellationToken cancellationToken)
{
string id = string.IsNullOrWhiteSpace(conversationId)
? Guid.NewGuid().ToString("N")
: conversationId;
if (_entries.TryGetValue(id, out Entry? entry) &&
entry.ExpiresAt > _timeProvider.GetUtcNow())
{
if (!entry.Provider.Equals(provider, StringComparison.OrdinalIgnoreCase))
throw new ConversationProviderMismatchException(id, entry.Provider, provider);
return ValueTask.FromResult(new ConversationSnapshot(
id, entry.Provider, entry.Messages, entry.ProviderConversationId));
}
return ValueTask.FromResult(new ConversationSnapshot(
id, provider, Array.Empty<ChatMessage>(), null));
}
public ValueTask SaveAsync(
string conversationId,
string provider,
IReadOnlyList<ChatMessage> messages,
string? providerConversationId,
CancellationToken cancellationToken)
{
IReadOnlyList<ChatMessage> bounded = messages
.TakeLast(_options.MaximumConversationMessages)
.ToArray();
_entries[conversationId] = new Entry(
provider,
bounded,
providerConversationId,
_timeProvider.GetUtcNow().AddMinutes(_options.ConversationLifetimeMinutes));
return ValueTask.CompletedTask;
}
}| Gateway-managed | Provider-managed |
|---|---|
Store and resend bounded ChatMessage history. | Store the returned provider conversation identifier. |
| Portable across providers. | Conversation remains bound to the issuing provider. |
| Gateway controls retention. | Provider controls remote state and retention semantics. |
ChatResponse.ConversationId is returned, pass it in the next ChatOptions.ConversationId instead of resending the same history. Never send one provider’s conversation identifier to another provider.using System.Diagnostics;
using Microsoft.Extensions.AI;
public sealed class AiGateway(
IAiProviderRegistry providers,
IConversationStore conversations,
TimeProvider timeProvider) : IAiGateway
{
public async Task<GatewayResult> SendAsync(
GatewayChatRequest request,
CancellationToken cancellationToken)
{
AiProviderDescriptor descriptor = providers.GetRequired(request.Provider);
ValidateCapabilities(request, descriptor);
ConversationSnapshot conversation = await conversations.GetOrCreateAsync(
request.ConversationId,
descriptor.Name,
cancellationToken);
var messages = conversation.Messages.ToList();
messages.Add(new ChatMessage(ChatRole.User, request.Message));
var options = BuildOptions(request, conversation);
long started = Stopwatch.GetTimestamp();
ChatResponse response = await descriptor.Client.GetResponseAsync(
messages,
options,
cancellationToken);
messages.AddRange(response.Messages);
await conversations.SaveAsync(
conversation.ConversationId,
descriptor.Name,
messages,
response.ConversationId,
cancellationToken);
return new GatewayResult(
conversation.ConversationId,
descriptor,
response,
Stopwatch.GetElapsedTime(started));
}
private static void ValidateCapabilities(
GatewayChatRequest request,
AiProviderDescriptor descriptor)
{
if (request.EnableTools && !descriptor.Capabilities.SupportsTools)
throw new UnsupportedProviderCapabilityException(descriptor.Name, "tools");
}
private static ChatOptions BuildOptions(
GatewayChatRequest request,
ConversationSnapshot conversation)
{
var options = new ChatOptions
{
ConversationId = conversation.ProviderConversationId
};
if (request.EnableTools)
options.Tools = [TimeTools.GetCurrentUtcTimeFunction];
return options;
}
}The service resolves a descriptor once. Provider identity, capability checks, model metadata, telemetry labels, and the actual client therefore cannot drift apart.
using System.ComponentModel;
using Microsoft.Extensions.AI;
public static class TimeTools
{
public static readonly AIFunction GetCurrentUtcTimeFunction =
AIFunctionFactory.Create(GetCurrentUtcTime);
[Description("Returns the current UTC time as an ISO 8601 string.")]
public static string GetCurrentUtcTime() =>
DateTimeOffset.UtcNow.ToString("O");
}.UseFunctionInvocation() handles the function-call loop: the model requests the function, the middleware invokes it, returns the result, and lets the model continue.
app.MapPost("/api/chat", async (
GatewayChatRequest request,
IAiGateway gateway,
HttpContext httpContext) =>
{
GatewayResult result = await gateway.SendAsync(
request,
httpContext.RequestAborted);
ChatResponse response = result.Response;
var dto = new GatewayChatResponse(
result.ConversationId,
result.Provider.Name,
response.ModelId ?? result.Provider.Model,
response.Text,
response.FinishReason?.ToString(),
response.Usage is null
? null
: new TokenUsageDto(
response.Usage.InputTokenCount,
response.Usage.OutputTokenCount),
(long)result.Duration.TotalMilliseconds);
return Results.Ok(dto);
})
.RequireRateLimiting("ai-gateway");app.MapGet("/api/providers", (IAiProviderRegistry registry) =>
Results.Ok(registry.GetEnabledProviders().Select(provider => new
{
provider.Name,
provider.Model,
provider.Capabilities
})));app.MapPost("/api/chat/stream", async (
GatewayChatRequest request,
IAiGateway gateway,
HttpContext context) =>
{
context.Response.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
await foreach (GatewayStreamEvent item in gateway.StreamAsync(
request,
context.RequestAborted))
{
string json = JsonSerializer.Serialize(item);
await context.Response.WriteAsync($"event: {item.Type}\n", context.RequestAborted);
await context.Response.WriteAsync($"data: {json}\n\n", context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
}
})
.RequireRateLimiting("ai-gateway");public async IAsyncEnumerable<GatewayStreamEvent> StreamAsync(
GatewayChatRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
AiProviderDescriptor descriptor = providers.GetRequired(request.Provider);
if (!descriptor.Capabilities.SupportsStreaming)
throw new UnsupportedProviderCapabilityException(descriptor.Name, "streaming");
ConversationSnapshot conversation = await conversations.GetOrCreateAsync(
request.ConversationId, descriptor.Name, cancellationToken);
var messages = conversation.Messages.ToList();
messages.Add(new ChatMessage(ChatRole.User, request.Message));
var assistantText = new StringBuilder();
string? providerConversationId = conversation.ProviderConversationId;
await foreach (ChatResponseUpdate update in descriptor.Client.GetStreamingResponseAsync(
messages,
new ChatOptions { ConversationId = providerConversationId },
cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
assistantText.Append(update.Text);
yield return new GatewayStreamEvent("delta", update.Text);
}
providerConversationId ??= update.ConversationId;
}
messages.Add(new ChatMessage(ChatRole.Assistant, assistantText.ToString()));
await conversations.SaveAsync(
conversation.ConversationId,
descriptor.Name,
messages,
providerConversationId,
cancellationToken);
yield return new GatewayStreamEvent("completed", conversation.ConversationId);
}HttpContext.RequestAborted. When the browser disconnects, the gateway should stop generating instead of continuing an abandoned, potentially billable request.Each keyed registration receives its own ChatClientBuilder. Cross-cutting behavior can be added once per client without changing the gateway service.
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("SwitchableAiGateway"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Microsoft.Extensions.AI")
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("Microsoft.Extensions.AI")
.AddOtlpExporter());| Capture | Guidance |
|---|---|
| Provider and model | Low-cardinality dimensions suitable for dashboards. |
| Duration and errors | Measure complete and streaming operations separately. |
| Usage | Only report token counts when the adapter returns them. |
| Prompt/response content | Disabled by default; development-only opt-in. |
| Tool calls | Record function name and outcome, never secret arguments. |
| Failure | Gateway response |
|---|---|
| Unsupported provider | 400 Bad Request |
| Invalid request | 400 Bad Request |
| Unsupported capability | 422 Unprocessable Entity |
| Context too large | 422 Unprocessable Entity |
| Provider authentication failure | 502 Bad Gateway |
| Provider rate limit | 429 or controlled 503 |
| Provider unavailable | 503 Service Unavailable |
| Gateway timeout | 504 Gateway Timeout |
| Client disconnected | Cancel without logging an application fault |
app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
{
IExceptionHandlerFeature? feature =
context.Features.Get<IExceptionHandlerFeature>();
(int status, string title) = feature?.Error switch
{
UnsupportedAiProviderException => (400, "Unsupported AI provider"),
ProviderSelectionDisabledException => (400, "Provider selection is disabled"),
UnsupportedProviderCapabilityException => (422, "Capability not supported"),
ConversationProviderMismatchException => (409, "Conversation belongs to another provider"),
OperationCanceledException when context.RequestAborted.IsCancellationRequested
=> (499, "Client closed request"),
TimeoutException => (504, "AI gateway timeout"),
_ => (503, "AI provider unavailable")
};
context.Response.StatusCode = status;
await Results.Problem(statusCode: status, title: title)
.ExecuteAsync(context);
}));builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("ai-gateway", limiter =>
{
limiter.PermitLimit = 20;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueLimit = 0;
});
});
app.UseRateLimiter();A fake client proves the gateway depends on the abstraction rather than network behavior.
using Microsoft.Extensions.AI;
public sealed class FakeChatClient : IChatClient
{
public ChatClientMetadata Metadata { get; } =
new("fake", new Uri("https://fake.invalid"), "fake-model");
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string prompt = messages.Last().Text;
return Task.FromResult(new ChatResponse(
new ChatMessage(ChatRole.Assistant, $"FAKE: {prompt}"))
{
ModelId = "fake-model",
FinishReason = ChatFinishReason.Stop
});
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (string chunk in new[] { "FAKE", ": ", messages.Last().Text })
{
cancellationToken.ThrowIfCancellationRequested();
yield return new ChatResponseUpdate(ChatRole.Assistant, chunk);
await Task.Yield();
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose() { }
}[Fact]
public void Explicit_provider_returns_matching_descriptor()
{
AiProviderDescriptor descriptor = registry.GetRequired("ollama");
Assert.Equal("ollama", descriptor.Name);
Assert.Same(ollamaClient, descriptor.Client);
}
[Fact]
public void Unknown_provider_is_rejected()
{
Assert.Throws<UnsupportedAiProviderException>(
() => registry.GetRequired("user-controlled-url"));
}Add tests for default selection, disabled request-level switching, history truncation, provider-bound conversations, cancellation, streaming order, capability rejection, and secret redaction.
# Terminal 1
ollama serve
# Terminal 2
dotnet run --project src/SwitchableAiGateway.Apicurl -X POST http://localhost:5000/api/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Explain dependency injection in simple terms.",
"conversationId": "demo-123"
}' curl -X POST http://localhost:5000/api/chat \
-H "Content-Type: application/json" \
-d '{
"message": "Explain dependency injection in simple terms.",
"provider": "openai"
}' curl -N -X POST http://localhost:5000/api/chat/stream \
-H "Content-Type: application/json" \
-d '{
"message": "Give me three reasons to use IChatClient.",
"provider": "ollama"
}' AiGateway:DefaultProvider, repeat the same HTTP request, and watch the underlying model change without editing endpoint or gateway code.This gateway is intentionally not a RAG system, agent framework, failover router, or billing platform. Those are follow-up concerns that can now build on a clean provider-neutral boundary.
No. It standardizes the application contract, not model behavior. Capability metadata remains necessary for streaming, tools, usage reporting, context limits, and provider-managed conversations.
The Microsoft-authored Ollama adapter is deprecated and recommends OllamaSharp. OllamaApiClient implements IChatClient and composes with ChatClientBuilder middleware.
No. For provider-managed state, pass the returned identifier through ChatOptions.ConversationId and send only new messages. Keep the conversation bound to that provider.
Only cautiously. Repeating a completed generation can incur another charge, and tool calls can duplicate side effects. Restrict retries to known-safe transient failures.
Provider adapters evolve quickly. The sample pins tested versions centrally in Directory.Packages.props so one file communicates the verified dependency set.