Skip to main content

Choosing a model and an SDK in .NET

Two decisions, not one: which model answers the question, which library carries the call, and how to keep either one cheap to change.

beginner15 min.NET 10.0

You now know what the two calls do. This lesson is the decision that comes before either of them on a real project: which model, and which library. It is usually made in the first hour of a spike, by whoever pasted the first sample, and then lived with for a year.

It does not have to be. Made deliberately, it is two decisions rather than one, and only one of them is expensive to change.

Two decisions, not one

Which model is a question about capability, cost, latency and where the data is allowed to go. Which library is a question about how your C# reaches whichever model you picked. Conflating them is what produces a codebase where changing model means changing two hundred files.

The sits between the two: the same gpt-4o-mini weights are reachable through OpenAI directly, through an Azure OpenAI in your own subscription, or through a gateway your platform team runs — with different auth, different quota, different residency and identical prompts.

The three layers of a .NET AI stack

LayerPackagesWhat it owns
AbstractionMicrosoft.Extensions.AIIChatClient, IEmbeddingGenerator, the Use… middleware pipeline. Calls nothing on its own
Provider clientOpenAI, Azure.AI.OpenAI, a vendor SDK, OllamaSharpThe HTTP call, auth, provider-specific request shapes
OrchestrationSemantic Kernel, Microsoft Agent FrameworkPlugins, planners, agent loops, multi-step state

The rule that follows is short: your application code depends on the abstraction, exactly one file references a provider client, and you add the orchestration layer only when you can name the problem it solves. The lessons so far have all obeyed it — every sample took an IChatClient and none of them knew who was serving it.

Skipping the abstraction is a real option and occasionally the right one: a single integration against a single provider you own, with no plan to compare anything, does not need a seam. Know that you are choosing it, and know that the cost of adding it later is proportional to how much code has learned the provider's types.

Choosing a model

There is no ranking, only a fit against constraints. In rough order of how often each one decides it:

ConstraintThe question to ask
Task difficultyDoes the cheapest model pass your evaluation set? Most extraction and classification does not need a frontier model
CostPrice per million input and output tokens, multiplied by the traffic you actually expect, not by a demo
LatencyTime to first token for a human-facing feature; total time for a batch job
Context windowThe budget from Tokens and context windows, not the marketing number
Hosting and residencyWhich cloud, which region, whose compliance boundary. This one is frequently the whole answer
Feature supportStructured output, tool calling, images, and how reliably rather than whether
QuotaTokens per minute you can actually get, and what happens at the ceiling

Two habits make the choice cheap to revisit. Start at the bottom of the range — pick the smallest model, run it against a set of examples you have graded by hand, and move up only where it fails. Doing it the other way round leaves you with a system that works and a bill nobody can explain. And route by task: a small model classifying an incoming message, a larger one drafting the reply, is a standard shape and usually cheaper than either model alone.

Whatever you pick, pin the dated version rather than the family alias. gpt-4o is a pointer that moves under you; gpt-4o-2024-11-20 is a dependency you upgraded deliberately.

Choosing the SDK

The realistic options for a .NET service, and when each is the answer:

  • Microsoft.Extensions.AI over a provider client — the default, and what this course assumes. You get IChatClient, IEmbeddingGenerator, and caching, logging, telemetry and automatic function invocation as pipeline stages rather than as code you wrote.
  • Azure.AI.OpenAI — when the model runs as an Azure OpenAI deployment. Entra ID auth instead of a key, your subscription's quota, your region. Note that you address a deployment name you chose, not a model name, so the string in your config means something different to every environment.
  • OpenAI — the same models direct from OpenAI, with an API key. Simplest to start, and the one to reach for in a spike.
  • A vendor SDK — Anthropic, Mistral and the rest, where you want the vendor's own surface and are willing to write the IChatClient adapter if one is not published.
  • A local runtime (OllamaSharp, ONNX Runtime GenAI) — when the data cannot leave the machine, when you want a test fixture that costs nothing, or when the task is small enough for a small model. Also the cheapest way to run your evaluation set fifty times.

One composition root, three branches, and nothing downstream can tell which ran:

Program.cs
using System.ClientModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OllamaSharp;
using OpenAI;
 
var builder = Host.CreateApplicationBuilder(args);
AiOptions ai = builder.Configuration.GetSection("Ai").Get<AiOptions>()!;
 
// The only place in the application that knows who serves the model.
IChatClient client = ai.Provider switch
{
    // Managed identity in Azure, your developer identity locally. No key anywhere.
    "azure" => new AzureOpenAIClient(new Uri(ai.Endpoint!), new DefaultAzureCredential())
        .GetChatClient(ai.Deployment!)
        .AsIChatClient(),
 
    "openai" => new OpenAIClient(new ApiKeyCredential(ai.ApiKey!))
        .GetChatClient(ai.Model!)
        .AsIChatClient(),
 
    // A local model, for tests and for data that must not leave the box.
    "ollama" => new OllamaApiClient(new Uri(ai.Endpoint!), ai.Model!),
 
    _ => throw new InvalidOperationException($"Unknown Ai:Provider '{ai.Provider}'."),
};
 
builder.Services
    .AddChatClient(client)
    .UseLogging()
    .UseOpenTelemetry();

The options class is worth as much as the switch. Configuration is where a model choice becomes visible to an operator instead of buried in a constructor:

AiOptions.cs
/// <summary>Everything about the model choice that differs between environments.</summary>
public sealed class AiOptions
{
    /// <summary>azure | openai | ollama — the only provider-specific string in config.</summary>
    public required string Provider { get; init; }
 
    /// <summary>Dated model version, never a family alias: 'gpt-4o-mini-2024-07-18'.</summary>
    public string? Model { get; init; }
 
    /// <summary>Azure addresses a deployment you named, which may not match the model.</summary>
    public string? Deployment { get; init; }
 
    public string? Endpoint { get; init; }
 
    /// <summary>Absent on Azure, where the credential replaces it.</summary>
    public string? ApiKey { get; init; }
}

When you do need something only one provider offers, you do not have to abandon the abstraction to get it. GetService reaches the underlying client, and RawRepresentation on a response reaches the provider's own object — an escape hatch in one place beats a provider type in every signature.

Keeping the choice reversible

Reversible does not mean free. Be precise about what a swap actually costs.

Transfers as-is. Anything typed against IChatClient or IEmbeddingGenerator: your budgeting, your parsing, your validation, your retries, your tests. That is the point of the seam, and in a codebase that respects it a provider change really is one file plus configuration.

Does not transfer. More than people expect:

  • Prompts. They are tuned to a model's habits. Expect to re-tune, and expect the difference to show up in your evaluation set rather than in a demo.
  • Token counts. A different family means a different vocabulary, so your budget arithmetic and every cached count are wrong until recomputed.
  • Embeddings. Vectors from two models are not comparable at all. Changing an embedding model means re-embedding the entire corpus — the most expensive swap in this list, and the reason to choose that one with more care than the rest.
  • Tool-calling and structured-output reliability. Both are supported almost everywhere and are not equally good anywhere.
  • Cost and latency. Which is usually why you are swapping.

Next, and last in this course: what all of this reliably does well, how it fails, and what you put around it before it goes anywhere near a user.

Check yourself

5 questions about judgement calls from this lesson, 4 of them to pass. Answers are kept in this browser only, and passing does not mark the lesson complete — that stays the button below.

  1. Question 1 of 5A team wants to be able to change provider later without a rewrite. Which decision actually buys that?Choose one answer.
  2. Question 2 of 5You swap one chat model for another behind a clean `IChatClient` seam. Which of these carry over with no work? Select all that apply.Choose every answer that applies.
  3. Question 3 of 5True or false: with `Azure.AI.OpenAI` you address the model by the same name you would use against OpenAI directly.Choose one answer.
  4. Question 4 of 5Put the steps of choosing a model for a new feature in the order that keeps it cheap.Use the arrow buttons to put these in order.
    1. Run the cheapest candidate model against that set.
    2. Assemble twenty real inputs and grade the answers you would accept.
    3. Write down the constraints that are not negotiable: residency, latency, quota, budget.
    4. Move up a tier only for the cases the cheap model failed.
    5. Pin the dated model version in configuration.

  5. Question 5 of 5One feature needs a request option only your provider offers, and `ChatOptions` has no property for it. What do you do?Choose one answer.

Your score appears here. Answers are stored in this browser, so they follow the browser and not you.

Practise it

You write this one locally, in your own editor. Nothing here runs or reads your code.

One seam, three providers, one file that knows

Take the console app from the first exercise and make its provider a configuration value. Implement the composition-root switch over an AiOptions record with three branches — a hosted provider with a key, an Azure deployment with a credential, and a local runtime — and validate the options at start-up so a missing deployment name fails immediately rather than on the first request. Then write the test that keeps the seam honest: scan the loaded assembly and assert that no type outside the composition root references a provider namespace. Run the app twice against two of the three branches, changing configuration only.

In the repository
exercises/ai-fundamentals/05-provider-seam
Verify with
dotnet test exercises/ai-fundamentals/05-provider-seam

Self-reported and not verified. Nothing runs your code or reads your repository — this records what you say you did, in this browser.

Your notes

Plain text, saved in this browser as you type. Nothing is uploaded, and there is no account to sync it to — export from your profile page to carry it elsewhere.