[CS] CARLOS SEIDL PT
Articles

Microsoft.Extensions.AI, Semantic Kernel or the SDK Directly: Which to Use in .NET

Microsoft.Extensions.AI, Semantic Kernel or the provider SDK in .NET 10: the same task in all three layers, provider swap, pitfalls and an xUnit test.

By 9 min read
  • AI
  • Microsoft.Extensions.AI
  • Semantic Kernel
  • .NET 10
Capa do artigo: Microsoft.Extensions.AI, Semantic Kernel or the SDK Directly: Which to Use in .NET

For most cases in .NET, start with Microsoft.Extensions.AI: your service depends only on IChatClient, tool calling comes out of the pipeline, and switching providers or testing without a network becomes configuration. Use the provider SDK when you need a model feature the abstraction does not cover yet, isolated behind an adapter. Use Semantic Kernel when there is real orchestration: several steps, plugins, agents. The three options are not competitors; they are layers, and the right question is how much abstraction you need.

What I compared and what is simulated

I did the same task three times in .NET 10, with the packages checked on nuget.org on 10/23/2026: the OpenAI SDK 2.14.0, Microsoft.Extensions.AI 10.11.0 (with Microsoft.Extensions.AI.OpenAI 10.11.0) and Microsoft.SemanticKernel 1.81.0. These APIs change fast, so check the versions in your own project.

A warning before anything else: the model is simulated. No key was used and no paid service was called. For the layers that accept an IChatClient, the model is a fake, deterministic client that I wrote. For the real OpenAI SDK to run without a network, I started a local HTTP server that answers in the OpenAI chat format. Every output starts with the line SIMULATED model. No network, no API key. What is real are the packages, the types, the pipeline and dotnet test. For that reason, nothing here measures the response quality, cost or latency of a real model.

Three layers, not three competitors

The provider SDK talks to one vendor's API. It is the lowest layer, with that vendor's types.

Microsoft.Extensions.AI defines common interfaces, mainly IChatClient, and a middleware pipeline in the same style as ASP.NET Core.

Semantic Kernel is a larger framework that orchestrates plugins and agents, and it can run on top of an IChatClient. In the example, it runs on exactly the same client as the other options.

The task: summarizing an order with function calling

The task is to summarize an order. The text sent to the model is Order 1042: 3 items, total 349.90 USD. Summarize it and include the shipping status. The shipping status is not in the text: it comes from a function the model has to request (function calling, or tool calling). The function is local code, with no network:

C#
public static class OrderTools
{
    [Description("Returns the shipping status of an order.")]
    public static string GetShippingStatus(
        [Description("The order id")] int orderId) =>
        orderId == 1042
            ? "In transit, ETA 2 days"
            : "Unknown order";
}

The provider SDK directly: full control, vendor types

In the first option, the service uses the real OpenAI SDK. The tool is described by hand, with a JSON schema, and the tool-calling loop is mine: I call the model, and if it finishes by requesting a tool, I run the function and send the result back in a tool message.

C#
public sealed class SdkOrderSummary(ChatClient chat)
{
    static readonly ChatTool ShippingTool =
        ChatTool.CreateFunctionTool(
            "GetShippingStatus",
            "Returns the shipping status of an order.",
            BinaryData.FromString("""
            {"type":"object",
             "properties":{"orderId":{"type":"integer"}},
             "required":["orderId"]}
            """));

    public async Task<string> SummarizeAsync(Order order)
    {
        List<ChatMessage> messages =
            [new UserChatMessage(order.Prompt)];
        var options = new ChatCompletionOptions
            { Tools = { ShippingTool } };
        while (true)
        {
            ChatCompletion done = (await chat.CompleteChatAsync(
                messages, options)).Value;
            if (done.FinishReason != ChatFinishReason.ToolCalls)
                return done.Content[0].Text;

            messages.Add(new AssistantChatMessage(done));
            foreach (var call in done.ToolCalls)
            {
                using var args =
                    JsonDocument.Parse(call.FunctionArguments);
                var id = args.RootElement
                    .GetProperty("orderId").GetInt32();
                messages.Add(new ToolChatMessage(
                    call.Id, OrderTools.GetShippingStatus(id)));
            }
        }
    }
}

Look at the types: ChatCompletion, ToolChatMessage, ChatTool. All from the vendor, inside my service. Against the simulated local server, the SDK requested the tool, I ran it, and the final answer was Simulated summary: order 1042 has 3 items, total 349.90 USD. Shipping: In transit, ETA 2 days.

The upside: the model's features are available the day the SDK ships them. The cost: switching providers, or testing without a network, turns into a refactoring.

Microsoft.Extensions.AI: IChatClient and a middleware pipeline

In the second option, the service depends only on IChatClient. The tool is the same plain function, registered with AIFunctionFactory, with no hand-written schema and no loop:

C#
public sealed class OrderSummaryService(IChatClient chat)
{
    public async Task<string> SummarizeAsync(Order order)
    {
        var options = new ChatOptions
        {
            Tools = [AIFunctionFactory.Create(
                OrderTools.GetShippingStatus)],
        };
        var response =
            await chat.GetResponseAsync(order.Prompt, options);
        return response.Text;
    }
}

There is no loop because the pipeline runs the tool. It is built with ChatClientBuilder: first telemetry, then function invocation, and last a middleware of my own, for auditing, which inherits from DelegatingChatClient and logs every round trip to the model.

C#
public static IChatClient Build(
    IChatClient inner, Action<string> log) =>
    new ChatClientBuilder(inner)
        .UseOpenTelemetry(sourceName: "demo.ai")
        .UseFunctionInvocation()
        .Use(next => new AuditChatClient(next, log))
        .Build();

public sealed class AuditChatClient(
    IChatClient inner, Action<string> log)
    : DelegatingChatClient(inner)
{
    int round;

    public override Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        round++;
        log($"audit: model call {round}, tools offered: " +
            $"{options?.Tools?.Count ?? 0}");
        return base.GetResponseAsync(
            messages, options, cancellationToken);
    }
}

The fake model is a class that implements IChatClient: if a tool is offered and its result has not arrived yet, it requests the tool; otherwise, it returns the summary. It is the same client I use in tests and offline. When it runs, the audit saw two model calls, the one that requested the tool and the one that summarized (output further down).

Switching providers without touching the service

To switch providers, I kept the same service and the same pipeline and changed only the inner client: first the fake model, then the real OpenAI SDK, pointed at the local server and adapted with AsIChatClient().

C#
IChatClient[] providers =
[
    new FakeChatClient(),
    Providers.OpenAiSdk().AsIChatClient(),
];
foreach (var p in providers)
{
    var chat = Pipeline.Build(p, _ => { });
    var service = new OrderSummaryService(chat);
    Console.WriteLine($"{p.GetType().Name,-18}: " +
        await service.SummarizeAsync(order));
}

static class Providers
{
    public static ChatClient OpenAiSdk() => new(
        "simulated-model",
        new ApiKeyCredential("simulated"),
        new OpenAIClientOptions
        {
            Endpoint = new Uri(FakeOpenAiServer.Url + "/v1"),
        });
}

The real outputs of the pipeline, the provider switch and the telemetry (I wrapped the long lines):

Texto
# pipeline with the fake model
audit: model call 1, tools offered: 1
audit: model call 2, tools offered: 1
meai  : Simulated summary: order 1042 has 3 items,
        total 349.90 USD. Shipping: In transit, ETA 2 days.
model calls: 2

# provider switch
FakeChatClient    : Simulated summary: order 1042 has
    3 items, total 349.90 USD.
    Shipping: In transit, ETA 2 days.
OpenAIChatClient  : Simulated summary: order 1042 has
    3 items, total 349.90 USD.
    Shipping: "In transit, ETA 2 days".

# telemetry
span  : chat
  gen_ai.response.finish_reasons = ["stop"]
  gen_ai.response.model = simulated-model
  gen_ai.usage.input_tokens = 28
  gen_ai.usage.output_tokens = 24

The service did not change a single line. But notice the real difference: through the OpenAI adapter, the tool result reaches the model serialized as JSON, with quotes. Abstraction does not mean perfect equality between providers, and testing one does not replace testing the other.

Telemetry that comes out of the pipeline itself

UseOpenTelemetry produces a span with the response model, the finish reason and token usage, in the gen_ai.* attributes. Because it is the first middleware, it wraps the whole operation, including both round trips to the model. The tokens here are a simulated count that the fake client computes from the words in the text; they show the plumbing, not an estimate of cost.

The limit of Microsoft.Extensions.AI is the same as any abstraction's: it targets what providers have in common. A feature that is very specific to one model may require going down to the SDK.

Semantic Kernel on top of the same IChatClient

The third option brings new vocabulary. The function becomes a plugin, with the KernelFunction attribute; the plugin is registered in a Kernel; and the tool behavior is chosen in the execution settings, with FunctionChoiceBehavior.Auto(). Semantic Kernel runs on top of my IChatClient, and I needed UseKernelFunctionInvocation for it to invoke the kernel functions:

C#
public sealed class ShippingPlugin
{
    [KernelFunction,
     Description("Returns the shipping status of an order.")]
    public string GetShippingStatus(int orderId) =>
        OrderTools.GetShippingStatus(orderId);
}

public sealed class KernelOrderSummary(
    IChatClient chat, PromptExecutionSettings? settings = null)
{
    public async Task<string> SummarizeAsync(Order order)
    {
        var builder = Kernel.CreateBuilder();
        var withTools = chat.AsBuilder()
            .UseKernelFunctionInvocation().Build();
        builder.Services.AddSingleton(
            withTools.AsChatCompletionService());
        builder.Plugins.AddFromType<ShippingPlugin>("Shipping");
        var kernel = builder.Build();

        settings ??= new PromptExecutionSettings
        {
            FunctionChoiceBehavior =
                FunctionChoiceBehavior.Auto(),
        };
        var history = new ChatHistory();
        history.AddUserMessage(order.Prompt);
        var answer = await kernel
            .GetRequiredService<IChatCompletionService>()
            .GetChatMessageContentAsync(
                history, settings, kernel);
        return answer.Content ?? "";
    }
}

The project suppresses the experimental API warnings SKEXP0001 and SKEXP0010 in the .csproj, so part of this path is still marked as experimental. The result was the same summary, with two model calls. Semantic Kernel has much more than this (prompt templates, agents, vector integration), and I used none of it here. For one call with one tool, it is too much; it pays off when you orchestrate several steps.

Three configuration pitfalls, before and after

I ran each pitfall twice, on the simulated model: the first version and the fixed one.

First: a tool is offered, but there is no UseFunctionInvocation. The pipeline was just new ChatClientBuilder(new FakeChatClient()).Build(). No exception: the text came back empty, the finish reason was a tool request and one call was left pending. With .UseFunctionInvocation(), the summary comes back and nothing is left pending.

Second: the pipeline order. I put .Use(next => new AuditChatClient(...)) before .UseFunctionInvocation(). The audit saw one call, but the model was called twice. The first middleware in the list is the outermost one and only sees the whole operation. To count every round trip to the model, the audit goes after the invocation, closer to the model. To measure the whole operation, the outer position is the right one: it depends on what you want to measure.

Third: Semantic Kernel without FunctionChoiceBehavior. Registering the plugin is not enough. With an empty new PromptExecutionSettings(), the tool was not offered to the model: the summary came out, but without the shipping status, in a single call. With FunctionChoiceBehavior.Auto(), the function is called.

Texto
# pitfall 1, before
text   : ''
finish : tool_calls
pending: GetShippingStatus
# pitfall 1, after
text   : 'Simulated summary: order 1042 has 3 items,
          total 349.90 USD. Shipping: In transit, ETA 2 days.'
finish : stop
pending: none

# pitfall 2, before
audit: model call 1, tools offered: 1
real model calls: 2
# pitfall 2, after
audit: model call 1, tools offered: 1
audit: model call 2, tools offered: 1
real model calls: 2

# pitfall 3, before
Simulated summary: order 1042 has 3 items,
  total 349.90 USD. Shipping: not available.
model calls: 1
# pitfall 3, after
Simulated summary: order 1042 has 3 items,
  total 349.90 USD. Shipping: In transit, ETA 2 days.
model calls: 2

An xUnit test that protects the pipeline

So I do not have to rely on memory, five xUnit tests protect the pipeline with the fake model: the tool result reaches the summary, an unknown order reports Unknown order, the tool costs two round trips to the model, the audit sees all of them, and the Semantic Kernel path gives the same answer.

C#
static (OrderSummaryService, FakeChatClient, List<string>)
    Setup()
{
    var fake = new FakeChatClient();
    var log = new List<string>();
    var chat = Pipeline.Build(fake, log.Add);
    return (new OrderSummaryService(chat), fake, log);
}

[Fact]
public async Task Audit_sees_every_model_round_trip()
{
    var (service, fake, log) = Setup();
    await service.SummarizeAsync(Orders.Sample);
    Assert.Equal(fake.Calls, log.Count);
}

With the correct pipeline, all five pass. Then I swapped the order of the two pipeline lines, UseFunctionInvocation and the audit, and ran it again:

Texto
# correct pipeline
Passed!  - Failed: 0, Passed: 5, Skipped: 0, Total: 5

# order swapped
PipelineTests.Audit_sees_every_model_round_trip [FAIL]
   Assert.Equal() Failure: Values differ
Expected: 2
Actual:   1
Failed!  - Failed: 1, Passed: 4, Skipped: 0, Total: 5

The audit expected two calls and saw one: the second pitfall was caught by the test, before reaching production. The other four kept passing, and I undid the swap.

What this example does not show

No real model was used, so nothing here speaks to response quality, cost or latency. I also did not show streaming (the fake client does not even implement it), nor Semantic Kernel agents, memory and templates, nor the Microsoft Agent Framework. And the only SDK tested was OpenAI's; another provider's SDK has its own types, which I did not test.

How I choose the layer

  • One or two calls, a simple chat or one tool, and easy testing: Microsoft.Extensions.AI.
  • A new model feature the abstraction does not cover yet: the SDK directly, isolated behind an adapter.
  • Several steps, several agents, orchestration: Semantic Kernel.

When in doubt, start with IChatClient, which is the easiest to swap later. And one rule, in my opinion: pick one entry layer and keep the others hidden behind it. Mixing all three means three ways to configure, three ways to log and three fake models in the tests.

Summary

  • The provider SDK, Microsoft.Extensions.AI and Semantic Kernel are layers, not rivals.
  • Start with IChatClient and the ChatClientBuilder pipeline; the service stays the same when the provider changes.
  • Switching providers is not switching behavior: in the example, the tool result arrived with quotes through the OpenAI adapter.
  • Watch out for function invocation, pipeline order and FunctionChoiceBehavior in Semantic Kernel.
  • Keep a test with a fake model protecting all of that.

If you want to go beyond a single tool, also read MCP in .NET, about exposing tools and data to models with the Model Context Protocol, and RAG in .NET, which uses IChatClient to answer based on your own data. If you need help choosing the layer and taking AI to production, see the .NET backend service.

References

← all articles