Microsoft.Extensions.AI is the set of .NET libraries that gives every AI provider the same programming model: one IChatClient interface for chat models, one IEmbeddingGenerator interface for embeddings, and a middleware pipeline for caching, telemetry and tool calling. This guide is for C# developers who want to use it well in production, not just call a model once. You will learn the core types, streaming, tool calling, structured output, dependency injection, provider adapters, custom middleware and how to test code that depends on a language model.
What Is Microsoft.Extensions.AI?#
Microsoft.Extensions.AI (MEAI) plays the same role for generative AI that ILogger plays for logging: a small, stable abstraction that libraries and applications agree on, with provider-specific implementations plugged in underneath. It was built by the .NET team together with the Semantic Kernel team, and its core interfaces were extracted from Semantic Kernel so that the whole ecosystem could share them. Today Microsoft Agent Framework, the MCP C# SDK, the AI project templates and the vector data libraries all build on these types.
The functionality ships in two main packages:
Microsoft.Extensions.AI.Abstractionscontains the exchange types:IChatClient,IEmbeddingGenerator<TInput, TEmbedding>,ChatMessage,ChatOptions,ChatResponse, content types and tool types. Libraries that implement a provider usually reference only this package.Microsoft.Extensions.AIadds the higher-level utilities:ChatClientBuilder, middleware such asFunctionInvokingChatClientandOpenTelemetryChatClient, dependency injection helpers and structured output extensions. Applications normally reference this package plus one or more provider adapters.
The libraries became stable in May 2025 with version 9.5.0, moved to the 10.x line with .NET 10 in November 2025, and ship roughly monthly; 10.10.0 was released in September 2026. They target .NET Standard 2.0 as well as modern .NET, so they work on .NET 8, 9 and 10 and even in .NET Framework applications. Newer areas are marked experimental and raise the MEAI001 diagnostic until they stabilize: image generation (IImageGenerator), speech to text, text to speech, real-time clients, chat history reduction and chat routing with failover.
How Microsoft.Extensions.AI Works#
The core contract is deliberately small. IChatClient has two request methods and a service-lookup method:
GetResponseAsyncsends a list of messages and returns a completeChatResponse.GetStreamingResponseAsyncsends the same input and returns anIAsyncEnumerable<ChatResponseUpdate>that yields partial results as the model produces them.GetServicelets callers retrieve metadata or the underlying provider object, such asChatClientMetadataor the raw OpenAI client.
Everything else is data. The following table summarizes the types you will use daily.
| Type | Purpose | Key members |
|---|---|---|
ChatMessage | One message in a conversation | Role, Contents, Text, AuthorName |
ChatRole | Who produced the message | System, User, Assistant, Tool |
AIContent subclasses | Multimodal message parts | TextContent, DataContent, UriContent, FunctionCallContent, FunctionResultContent, TextReasoningContent, UsageContent |
ChatOptions | Per-request settings | Instructions, ModelId, Temperature, MaxOutputTokens, Tools, ToolMode, ResponseFormat, Reasoning, ConversationId |
ChatResponse | Complete result | Messages, Text, Usage, FinishReason, ModelId, ConversationId |
ChatResponseUpdate | One streamed chunk | Text, Contents, FinishReason |
AIFunction | A tool the model may call | created with AIFunctionFactory.Create |
Two design choices are worth understanding early. First, the interface supports both stateless and stateful services. With a stateless service you send the full history on every call. With a stateful service, such as the OpenAI Responses API, the service keeps history and returns a ConversationId that you pass back through ChatOptions.ConversationId, so you only send new messages. Second, any parameter a provider supports but the abstraction does not model can still reach the provider through ChatOptions.AdditionalProperties or RawRepresentationFactory, so the abstraction never blocks you from provider features.
Getting Started#
Install the package and a provider adapter. The OpenAI adapter works with OpenAI, with Azure OpenAI through the v1 endpoint, and with other OpenAI-compatible servers:
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAIA console chat loop needs only an IChatClient, a history list and the AddMessages helper, which appends every message from the response, including tool calls, back to the history:
using Microsoft.Extensions.AI;
// Fully qualify OpenAI.Chat.ChatClient: that namespace also defines a ChatMessage type.
IChatClient client = new OpenAI.Chat.ChatClient("gpt-5-mini",
Environment.GetEnvironmentVariable("OPENAI_API_KEY")!).AsIChatClient();
List<ChatMessage> history =
[
new(ChatRole.System, "You are a concise assistant for .NET developers."),
];
while (Console.ReadLine() is { Length: > 0 } input)
{
history.Add(new ChatMessage(ChatRole.User, input));
ChatResponse response = await client.GetResponseAsync(history);
Console.WriteLine(response.Text);
history.AddMessages(response);
}ChatResponse.Text concatenates the text content of all response messages, which is what you want for display. When you need details, inspect response.Messages, response.Usage for token counts or response.FinishReason to detect truncation.
Streaming Responses with GetStreamingResponseAsync#
Streaming is the default user experience for chat, because it shows the first tokens after a fraction of a second instead of making the user wait for the entire answer. GetStreamingResponseAsync accepts exactly the same inputs as GetResponseAsync, and C# await foreach makes consumption natural:
List<ChatResponseUpdate> updates = [];
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
history, new ChatOptions { MaxOutputTokens = 800 }, cancellationToken))
{
Console.Write(update.Text);
updates.Add(update);
}
// Rebuild a complete response from the chunks for history, logging and usage.
ChatResponse full = updates.ToChatResponse();
history.AddMessages(full);
Console.WriteLine($"\nFinish reason: {full.FinishReason}, tokens: {full.Usage?.TotalTokenCount}");Collecting the updates matters. Tool calls, usage information and the finish reason arrive as content inside the updates, and ToChatResponse merges them into one coherent ChatResponse. Always pass a CancellationToken from the HTTP request or UI, so that an abandoned request stops consuming tokens.
Tool Calling with AIFunctionFactory and UseFunctionInvocation#
Tool calling lets the model request that your code run a function, then use the result to finish its answer. MEAI splits the job in two. AIFunctionFactory.Create turns a .NET method or lambda into an AIFunction with a JSON schema derived from its parameters and [Description] attributes. FunctionInvokingChatClient, added with UseFunctionInvocation, runs the loop: it detects function call requests, invokes your functions, sends the results back and repeats until the model produces a final answer.
using System.ComponentModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
// appServices is the application's root IServiceProvider, for example app.Services.
IChatClient toolClient = client.AsBuilder()
.UseFunctionInvocation(configure: f =>
{
f.MaximumIterationsPerRequest = 5; // cap runaway tool loops
f.AllowConcurrentInvocation = true; // run independent calls in parallel
f.IncludeDetailedErrors = false; // do not leak exception text to the model
})
.Build(appServices);
ChatOptions options = new()
{
Tools = [AIFunctionFactory.Create(GetInvoiceTotalAsync)],
};
ChatResponse result = await toolClient.GetResponseAsync(
"How much is outstanding on invoice INV-2026-0042?", options);
[Description("Gets the outstanding amount, in euros, for an invoice number.")]
static async Task<decimal> GetInvoiceTotalAsync(
[Description("Invoice number such as INV-2026-0042.")] string invoiceNumber,
IServiceProvider services, // bound at invocation, hidden from the model
CancellationToken cancellationToken) // bound to the request token
{
await using AsyncServiceScope scope = services.CreateAsyncScope();
var invoices = scope.ServiceProvider.GetRequiredService<IInvoiceRepository>();
return await invoices.GetOutstandingAmountAsync(invoiceNumber, cancellationToken);
}Parameters of type IServiceProvider, AIFunctionArguments and CancellationToken are special: MEAI binds them at invocation time and leaves them out of the schema the model sees. The provider is the one the pipeline was built with, usually the root container, so create a scope inside the tool when it needs scoped services such as a DbContext.
For tools with side effects, wrap the function in ApprovalRequiredAIFunction. The invoking client then returns a ToolApprovalRequestContent instead of running the tool, and your application resumes the conversation with the user's decision. Tools returned by an MCP client derive from AIFunction, so remote MCP tools plug into the same Tools list.
Structured Output with GetResponseAsync of T#
Many features need data, not prose. The generic GetResponseAsync<T> extension generates a JSON schema from T, asks the model to comply (using the provider's native JSON schema support where available) and deserializes the result:
public enum Sentiment { Positive, Neutral, Negative }
public sealed record ReviewAnalysis(
Sentiment Sentiment,
string Summary,
string[] ProductsMentioned);
ChatResponse<ReviewAnalysis> analysis = await client.GetResponseAsync<ReviewAnalysis>(
$"Analyze this customer review:\n{reviewText}", cancellationToken: cancellationToken);
if (analysis.TryGetResult(out ReviewAnalysis? review))
{
Console.WriteLine($"{review.Sentiment}: {review.Summary}");
}TryGetResult returns false instead of throwing when the model's output does not deserialize, which is the right default for untrusted model output. ChatResponse<T> still exposes the raw messages and usage, so you can log failures and retry.
Building a Middleware Pipeline with ChatClientBuilder#
Middleware is where MEAI earns its place in production code. Every decorator is itself an IChatClient that wraps an inner client, so cross-cutting concerns compose without touching application code. The built-in components cover the common needs:
UseDistributedCachecaches complete responses in anyIDistributedCacheimplementation, keyed on the messages and options.UseFunctionInvocationruns tool loops, as shown earlier.UseOpenTelemetryemits traces and metrics that follow the OpenTelemetry semantic conventions for generative AI, including token usage.UseLoggingwrites requests and responses toILogger.ConfigureOptionssets defaults, such as a model ID, on every request.
Order matters. The first Use call becomes the outermost layer, and each subsequent call wraps the ones after it around the inner provider client:
IChatClient pipeline = new ChatClientBuilder(innerClient)
.UseDistributedCache(cache) // 1. outermost: cache hits skip everything below
.UseFunctionInvocation() // 2. tool loop runs inside the cache boundary
.UseOpenTelemetry(sourceName: "Contoso.Support",
configure: o => o.EnableSensitiveData = false)
.UseLogging(loggerFactory) // 4. logs every underlying model round trip
.ConfigureOptions(o => o.ModelId ??= "gpt-5-mini")
.Build();With this order, a cached answer returns immediately, a cache miss runs the entire tool loop, and telemetry records each individual model call inside that loop. Move UseOpenTelemetry above UseFunctionInvocation if you prefer one span per user request instead of one per model round trip. Keep EnableSensitiveData off in production unless your telemetry backend is approved for prompt and response content.
Dependency Injection: AddChatClient and Keyed Clients#
In ASP.NET Core and generic-host apps, register clients with AddChatClient and AddEmbeddingGenerator. Both return a builder, so the middleware chain reads the same as in the standalone case, and middleware can resolve services such as IDistributedCache and ILoggerFactory from the container. Keyed registrations let different features use different models:
using Microsoft.Extensions.AI;
using OpenAI;
var builder = WebApplication.CreateBuilder(args);
var openAI = new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]!);
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("redis"));
builder.Services.AddChatClient(openAI.GetChatClient("gpt-5").AsIChatClient())
.UseDistributedCache()
.UseFunctionInvocation()
.UseOpenTelemetry();
builder.Services.AddKeyedChatClient("fast", openAI.GetChatClient("gpt-5-mini").AsIChatClient())
.UseOpenTelemetry();
builder.Services.AddEmbeddingGenerator(
openAI.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator())
.UseOpenTelemetry();
var app = builder.Build();
app.MapPost("/tickets/classify", async (
TicketRequest ticket,
[FromKeyedServices("fast")] IChatClient fastClient,
CancellationToken ct) =>
{
var result = await fastClient.GetResponseAsync<TicketCategory>(ticket.Body,
cancellationToken: ct);
return result.TryGetResult(out var category) ? Results.Ok(category) : Results.StatusCode(502);
});
app.Run();
public sealed record TicketRequest(string Body);
public sealed record TicketCategory(string Area, bool Urgent);Registrations default to singleton lifetime, which suits provider clients because they are thread-safe and reuse HTTP connections. Both methods accept an optional ServiceLifetime if a pipeline must be scoped.
Provider Implementations: OpenAI, Azure OpenAI, Ollama and More#
An IChatClient comes either from an adapter extension method or from a provider library that implements the interface directly.
| Provider | Package | How you get an IChatClient | Notes |
|---|---|---|---|
| OpenAI | Microsoft.Extensions.AI.OpenAI | chatClient.AsIChatClient() | Also EmbeddingClient.AsIEmbeddingGenerator(); Responses API adapter is experimental (OPENAI001) |
| Azure OpenAI in Microsoft Foundry | Microsoft.Extensions.AI.OpenAI | OpenAI ChatClient with the /openai/v1/ endpoint | Recommended path for Azure, supports Entra ID |
| Azure AI Inference | Microsoft.Extensions.AI.AzureAIInference | ChatCompletionsClient.AsIChatClient(model) | Underlying SDK is retired; last adapter preview in November 2025 |
| Ollama | OllamaSharp | new OllamaApiClient(uri, model) implements IChatClient | Microsoft.Extensions.AI.Ollama is deprecated in its favor |
| ONNX Runtime GenAI | Microsoft.ML.OnnxRuntimeGenAI | new OnnxRuntimeGenAIChatClient(modelPath) | In-process local models |
The Azure AI Inference row deserves attention because many 2024 and 2025 samples use it. Microsoft's Foundry documentation now describes the Azure AI Inference SDK as retired and recommends the OpenAI SDK against the v1 endpoint, which also serves non-OpenAI Foundry models that support the chat completions API. Switching an existing MEAI application is a one-line change at composition time:
#pragma warning disable OPENAI001
using System.ClientModel.Primitives;
using Azure.Identity;
using Microsoft.Extensions.AI;
using OllamaSharp;
using OpenAI;
using OpenAI.Chat;
// Azure OpenAI in Microsoft Foundry, keyless via Entra ID.
IChatClient azure = new ChatClient(
model: "gpt-5-mini", // deployment name
authenticationPolicy: new BearerTokenPolicy(
new DefaultAzureCredential(), "https://ai.azure.com/.default"),
options: new OpenAIClientOptions
{
Endpoint = new Uri("https://contoso-ai.openai.azure.com/openai/v1/")
})
.AsIChatClient();
// Local development with Ollama.
IChatClient local = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2");Community and vendor packages provide implementations for other providers, including Google Gemini and Amazon Bedrock. Because they all implement the same interface, the rest of your pipeline does not change.
Writing a Custom DelegatingChatClient#
When the built-in middleware is not enough, derive from DelegatingChatClient. It forwards every call to InnerClient, so you override only what you need. The following middleware removes email addresses from user messages before they leave your network, a common requirement when prompts may contain customer data:
using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
public sealed partial class EmailRedactingChatClient(IChatClient innerClient)
: DelegatingChatClient(innerClient)
{
public override Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages,
ChatOptions? options = null, CancellationToken cancellationToken = default) =>
base.GetResponseAsync(Redact(messages), options, cancellationToken);
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
CancellationToken cancellationToken = default) =>
base.GetStreamingResponseAsync(Redact(messages), options, cancellationToken);
private static List<ChatMessage> Redact(IEnumerable<ChatMessage> messages) =>
[.. messages.Select(message =>
{
if (message.Role != ChatRole.User) return message;
ChatMessage copy = message.Clone();
copy.Contents = [.. copy.Contents.Select(content => content is TextContent text
? new TextContent(EmailPattern().Replace(text.Text, "[email]"))
: content)];
return copy;
})];
[GeneratedRegex(@"[\w.+-]+@[\w-]+(\.[\w-]+)+")]
private static partial Regex EmailPattern();
}
public static class EmailRedactionExtensions
{
public static ChatClientBuilder UseEmailRedaction(this ChatClientBuilder builder) =>
builder.Use(inner => new EmailRedactingChatClient(inner));
}The redaction works on copies, so the caller's history is not modified, and it only replaces TextContent, so images and tool results pass through untouched. The Use* extension method is the convention that makes custom middleware feel native: consumers add it with one line in their builder chain. For lightweight cases, ChatClientBuilder.Use also accepts a delegate that runs around both the streaming and non-streaming paths, which avoids writing a class at all.
Testing Code That Depends on IChatClient#
Because consumers depend on an interface, unit tests do not need a network or a model. A small fake that returns canned responses and records requests covers most business logic:
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
public sealed class FakeChatClient(params string[] replies) : IChatClient
{
private readonly Queue<string> _replies = new(replies);
public List<ChatMessage[]> Requests { get; } = [];
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages,
ChatOptions? options = null, CancellationToken cancellationToken = default)
{
Requests.Add([.. messages]);
string reply = _replies.Count > 0 ? _replies.Dequeue() : "";
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply)));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatResponse response = await GetResponseAsync(messages, options, cancellationToken);
foreach (ChatResponseUpdate update in response.ToChatResponseUpdates())
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose() { }
}
public sealed class TicketClassifier(IChatClient chatClient)
{
public async Task<TicketCategory> ClassifyAsync(string body, CancellationToken ct = default)
{
var response = await chatClient.GetResponseAsync<TicketCategory>(
$"Classify this support ticket:\n{body}", cancellationToken: ct);
return response.TryGetResult(out TicketCategory? category)
? category
: throw new InvalidOperationException("The model returned an unexpected format.");
}
}
public sealed class TicketClassifierTests
{
[Fact]
public async Task Classifies_double_charge_as_billing()
{
var fake = new FakeChatClient("""{"area":"Billing","urgent":true}""");
var classifier = new TicketClassifier(fake);
TicketCategory category = await classifier.ClassifyAsync("I was charged twice!");
Assert.Equal("Billing", category.Area);
Assert.Contains("charged twice", fake.Requests[0][^1].Text);
}
}Fakes verify your code: prompt construction, parsing, error handling and branching. They say nothing about whether the model gives good answers. For that, add evaluation tests with the Microsoft.Extensions.AI.Evaluation libraries, which score real responses for relevance, groundedness and other metrics and can cache responses so repeated CI runs stay fast and cheap.
Microsoft.Extensions.AI vs Provider SDKs vs Agent Frameworks#
MEAI is not the only way to call a model from .NET. The table shows when each layer is the right tool.
| Criterion | Microsoft.Extensions.AI | Provider SDK only (for example OpenAI) | Microsoft Agent Framework |
|---|---|---|---|
| Primary purpose | Provider-neutral model access and middleware | Full access to one provider's API | Agents, sessions and multi-agent workflows |
| Provider portability | High | None | High (built on IChatClient) |
| Newest provider features | Usually available, or through raw options | Immediate | Through the underlying chat client |
| Middleware and DI | Built in | Manual | Built in, plus agent middleware |
| Testability | Easy with fakes | Harder, needs mocks of concrete clients | Easy through IChatClient and AIAgent |
| Best for | Most app features, libraries, reusable components | Provider-specific features such as audio or fine-tuning | Goal-directed, multi-step, tool-heavy scenarios |
A common production mix is MEAI everywhere, the provider SDK for the few features the abstraction does not cover, and Agent Framework where orchestration is the real problem. For a map of the whole ecosystem, see AI in .NET: The Complete Landscape.
Best Practices#
- Depend on
IChatClientin your services. Construct provider clients only at the composition root, so switching providers or models is configuration. - Always propagate cancellation. Pass request tokens into every
GetResponseAsyncand streaming loop to stop paying for abandoned requests. - Cap tool loops. Set
MaximumIterationsPerRequestand keep tool sets small and specific; large tool lists reduce selection accuracy and raise token costs. - Keep secrets and stack traces away from the model. Leave
IncludeDetailedErrorsoff in production and return safe, descriptive error strings from tools instead. - Instrument and cache deliberately. Use OpenTelemetry for every client, and cache only deterministic, non-personalized prompts.
- Prefer
TryGetResultfor structured output. Treat model JSON as untrusted input and validate business rules after deserialization. - Isolate experimental APIs. Suppress
MEAI001only in the files that use experimental features.
Common Pitfalls#
- Forgetting to add response messages to history. Appending only the text, instead of calling
AddMessages, drops tool call and result messages and confuses the next turn. - Mixing up
ChatMessagetypes. The OpenAI library has its ownChatMessage; importing both namespaces causes ambiguity. Prefer MEAI types in application code and alias when necessary. - Wrong middleware order. Placing caching inside the tool loop, or telemetry outside it when you wanted per-call spans, produces confusing behavior. Decide what each layer should see.
- Resending history to a stateful service. When a response returns a
ConversationId, pass it back inChatOptionsand send only new messages, or you pay for the same tokens twice. - Embedding with mismatched models. Documents and queries embedded with different models or dimensions silently produce meaningless similarity scores.
- Building on deprecated adapters.
Microsoft.Extensions.AI.Ollamais deprecated in favor of OllamaSharp, and the Azure AI Inference SDK is retired.
Frequently Asked Questions#
Is Microsoft.Extensions.AI a replacement for Semantic Kernel?#
It replaces the lowest layer of Semantic Kernel, the model connectors, and Semantic Kernel itself now builds on it. Orchestration features such as agents and workflows live in Microsoft Agent Framework, which is the successor to Semantic Kernel and also builds on IChatClient.
Does Microsoft.Extensions.AI support the OpenAI Responses API?#
Yes. The OpenAI adapter provides AsIChatClient for the Responses client in addition to chat completions. That adapter is marked experimental with the OPENAI001 diagnostic because the underlying Responses API surface in the OpenAI .NET library is still experimental.
How do I use a different model per request?#
Set ChatOptions.ModelId on the request, or use ConfigureOptions in the pipeline to supply a default that individual calls can override. For different providers or cost tiers, register keyed clients and inject them with [FromKeyedServices].
Can I call tools without the automatic invocation loop?#
Yes. Omit UseFunctionInvocation, inspect the response for FunctionCallContent, run the functions yourself and add FunctionResultContent messages before calling the model again. This is useful when every tool call needs custom authorization, auditing or user confirmation.
How should I test prompts and answer quality?#
Use fakes for unit tests of your own logic, and use the Microsoft.Extensions.AI.Evaluation libraries for quality. The evaluators score real responses for metrics such as relevance, completeness and groundedness, and response caching keeps repeated CI runs affordable.
Summary#
IChatClientandIEmbeddingGeneratorgive every provider one programming model, and the wider .NET AI ecosystem builds on them.- Streaming, multimodal input, tool calling and structured output are first-class, with
GetResponseAsync<T>returning typed results. ChatClientBuildermiddleware adds caching, tool loops, OpenTelemetry and logging, and the order ofUsecalls defines behavior.- Register clients with
AddChatClient,AddKeyedChatClientandAddEmbeddingGeneratorfor idiomatic dependency injection. - For Azure, use the OpenAI adapter with the v1 endpoint; for local models, use OllamaSharp or ONNX Runtime GenAI.
- Write custom behavior as a
DelegatingChatClientwith aUse*extension, and test with fakes plus evaluation.