Are you still wiring 3 SDKs, 5 auth flows, and a dozen model names just to call await chat(...)
? Let’s fix that in one sitting.
This post shows how to add AI to a Blazor app using Microsoft.Extensions.AI so you write against one clean interface, not a pile of vendor SDKs. I’ll wire up:
- Parsing messy input into clean JSON (from an email, log, or free‑form text)
- Quick sentiment score
- Content generation with streaming
- GitHub Models for free dev usage (no credit card), and how to switch to OpenAI or Azure later without changing your Blazor components
I’ll use Blazor Server so secrets stay on the server (don’t ship keys to the browser). Everything here is copy‑paste friendly.
Why use Microsoft.Extensions.AI?
I ran into the same mess you probably did: different SDKs, different option objects, different streaming types. Microsoft.Extensions.AI gives you a single IChatClient
interface across providers. In practice that means:
- One interface:
IChatClient
withGetResponseAsync
andGetStreamingResponseAsync
- Same message types:
ChatMessage
,ChatRole
,ChatOptions
- Swap providers by changing DI setup, not the component code
It also plays well with DI, logging, and middleware you already use in .NET.
Tip: start with GitHub Models (free for dev), then swap to Azure or OpenAI for prod. Your Blazor code doesn’t care.
Project setup (Blazor Server)
# 1) Create app
dotnet new blazorserver -n BlazorAi
cd BlazorAi
# 2) Add packages
# Core abstractions + OpenAI adapter
DotNet add package Microsoft.Extensions.AI --prerelease
DotNet add package Microsoft.Extensions.AI.OpenAI --prerelease
# OpenAI official client (used behind the abstraction)
DotNet add package OpenAI
# Azure client is optional (only if you want Azure)
DotNet add package Azure.AI.OpenAI
DotNet add package Azure.Identity
Note: package names are case insensitive in CLI, write them as you like (
dotnet add package ...
).
Create an appsettings.Development.json section (no secrets, just defaults):
{
"AI": {
"Provider": "github",
"Model": "openai/gpt-4o-mini"
}
}
Now store the token with user‑secrets (so it isn’t in source control):
# If using GitHub Models locally, create a fine‑grained PAT with models scope
# then store it as AI:Key (or rely on GITHUB_TOKEN in Actions/Codespaces)
dotnet user-secrets init
DotNet user-secrets set "AI:Key" "ghp_xxx_your_models_pat"
If you use OpenAI later:
DotNet user-secrets set "AI:Provider" "openai"
DotNet user-secrets set "AI:Model" "gpt-4o-mini"
DotNet user-secrets set "AI:Key" "sk-your-openai-key"
If you use Azure later:
DotNet user-secrets set "AI:Provider" "azure"
DotNet user-secrets set "AI:Azure:Endpoint" "https://YOUR.azure.com/"
DotNet user-secrets set "AI:Azure:Deployment" "gpt-4o-mini"
# Use managed identity or store a key depending on your setup
Program.cs – one DI setup, three providers
Below is a compact DI setup that returns the same IChatClient
for GitHub Models, OpenAI, or Azure OpenAI. Your Blazor pages only see IChatClient
.
// Program.cs
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Chat;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<IChatClient>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var provider = config["AI:Provider"] ?? "github"; // github | openai | azure
if (string.Equals(provider, "azure", StringComparison.OrdinalIgnoreCase))
{
var endpoint = new Uri(config["AI:Azure:Endpoint"] ?? throw new InvalidOperationException("Missing AI:Azure:Endpoint"));
var deployment = config["AI:Azure:Deployment"] ?? throw new InvalidOperationException("Missing AI:Azure:Deployment"));
// Use managed identity locally if possible
var aoai = new AzureOpenAIClient(endpoint, new DefaultAzureCredential());
return aoai.GetChatClient(deployment).AsIChatClient();
}
if (string.Equals(provider, "openai", StringComparison.OrdinalIgnoreCase))
{
var model = config["AI:Model"] ?? "gpt-4o-mini";
var key = config["AI:Key"] ?? throw new InvalidOperationException("Missing AI:Key");
// OpenAI hosted
ChatClient chat = new(model, key);
return chat.AsIChatClient();
}
// Default: GitHub Models (free for dev)
{
var model = config["AI:Model"] ?? "openai/gpt-4o-mini"; // note model id format
var token = config["AI:Key"]
?? Environment.GetEnvironmentVariable("GITHUB_TOKEN")
?? throw new InvalidOperationException("Missing AI:Key or GITHUB_TOKEN for GitHub Models");
// GitHub Models uses an OpenAI‑compatible chat/completions API
// Base URL: https://models.github.ai/inference/
var options = new OpenAIClientOptions { Endpoint = new Uri("https://models.github.ai/inference/") };
var cred = new ApiKeyCredential(token);
ChatClient chat = new(model, cred, options);
return chat.AsIChatClient();
}
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
Notes
- In Codespaces or GitHub Actions,
GITHUB_TOKEN
is available automatically. Locally, use a PAT with themodels:read
permission. - For GitHub Models,
model
names look likeopenai/gpt-4o-mini
,meta/llama-3.1
, etc.
A tiny AI service (optional but handy)
I like to keep Blazor components slim. Here’s a simple service that wraps common calls. We’ll use it for parsing, sentiment, and writing.
// Services/AiService.cs
using System.Text.Json;
using Microsoft.Extensions.AI;
public record SentimentResult(double score, string label);
public record ContactRecord(string? name, string? email, string? phone, string? company);
public class AiService
{
private readonly IChatClient _chat;
private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public AiService(IChatClient chat) => _chat = chat;
public async Task<ContactRecord?> ExtractContactAsync(string blob, CancellationToken ct)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System, "Return ONLY compact JSON for the schema {name:string|null,email:string|null,phone:string|null,company:string|null} with no prose."),
new(ChatRole.User, $"Extract contact fields from:\n{blob}")
};
var text = await _chat.GetResponseAsync(messages, new ChatOptions { Temperature = 0.0 }, ct);
return SafeDeserialize<ContactRecord>(text);
}
public async Task<SentimentResult?> ScoreSentimentAsync(string text, CancellationToken ct)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You rate sentiment of a short text from -1 (very negative) to 1 (very positive). Return JSON {score:number,label:string}."),
new(ChatRole.User, text)
};
var reply = await _chat.GetResponseAsync(messages, new ChatOptions { Temperature = 0.0 }, ct);
return SafeDeserialize<SentimentResult>(reply);
}
public async IAsyncEnumerable<string> StreamWriteAsync(string instruction, string? topic, [EnumeratorCancellation] CancellationToken ct)
{
var messages = new List<ChatMessage>
{
new(ChatRole.System, "You write clear, short copy for web apps. Keep paragraphs small."),
new(ChatRole.User, $"Write content: {instruction}\nTopic: {topic}")
};
await foreach (var update in _chat.GetStreamingResponseAsync(messages, new ChatOptions { Temperature = 0.7 }, ct))
{
if (!string.IsNullOrEmpty(update.Text))
yield return update.Text;
}
}
private static T? SafeDeserialize<T>(string json)
{
try { return JsonSerializer.Deserialize<T>(json, _json); }
catch { return default; }
}
}
Register it in Program.cs
:
builder.Services.AddScoped<AiService>();
Blazor component: Parse, Score, Generate (with streaming)
Create Pages/AiPlayground.razor
with three cards: Parse, Sentiment, Writer.
@page "/ai"
@using Microsoft.Extensions.AI
@inject AiService Ai
<h3 class="mb-3">AI Playground</h3>
<div class="row g-3">
<!-- Parse -->
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Parse unstructured → JSON</div>
<div class="card-body">
<textarea class="form-control" rows="8" @bind="_parseInput" placeholder="Paste an email or note..."></textarea>
<button class="btn btn-primary mt-2" @onclick="OnParseAsync" disabled="@_busyParse">Parse</button>
@if (_parseOut != null)
{
<pre class="mt-3"><code>@System.Text.Json.JsonSerializer.Serialize(_parseOut, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))</code></pre>
}
</div>
</div>
</div>
<!-- Sentiment -->
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Sentiment score</div>
<div class="card-body">
<textarea class="form-control" rows="8" @bind="_sentimentInput" placeholder="Type a short message..."></textarea>
<button class="btn btn-secondary mt-2" @onclick="OnSentimentAsync" disabled="@_busySentiment">Score</button>
@if (_sentiment != null)
{
<div class="mt-3">
<div><b>Score:</b> @_sentiment.score</div>
<div><b>Label:</b> @_sentiment.label</div>
</div>
}
</div>
</div>
</div>
<!-- Writer (streaming) -->
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header">Content writer (streams)</div>
<div class="card-body">
<input class="form-control" placeholder="Topic (optional)" @bind="_topic" />
<textarea class="form-control mt-2" rows="4" @bind="_instruction" placeholder="E.g. Write a friendly product update, 120 words"></textarea>
<button class="btn btn-success mt-2" @onclick="OnWriteAsync" disabled="@_busyWrite">Write</button>
<button class="btn btn-outline-danger mt-2 ms-2" @onclick="CancelWrite" disabled="@(!_busyWrite)">Stop</button>
<pre class="mt-3"><code>@_streamBuffer</code></pre>
</div>
</div>
</div>
</div>
@code {
// Parse
string _parseInput = @"Hey team, it's Ana from Acme. Reach me at ana@acme.io or +1-202-555-0182. Let's talk pricing next week.";
ContactRecord? _parseOut;
bool _busyParse;
// Sentiment
string _sentimentInput = "This release is fantastic, speed feels way better!";
SentimentResult? _sentiment;
bool _busySentiment;
// Writer
string _instruction = "Write a friendly note about the new dark mode.";
string? _topic = "UI release";
string _streamBuffer = string.Empty;
bool _busyWrite;
CancellationTokenSource? _cts;
async Task OnParseAsync()
{
_busyParse = true;
try { _parseOut = await Ai.ExtractContactAsync(_parseInput, CancellationToken.None); }
finally { _busyParse = false; }
}
async Task OnSentimentAsync()
{
_busySentiment = true;
try { _sentiment = await Ai.ScoreSentimentAsync(_sentimentInput, CancellationToken.None); }
finally { _busySentiment = false; }
}
async Task OnWriteAsync()
{
_busyWrite = true;
_streamBuffer = string.Empty;
_cts = new CancellationTokenSource();
try
{
await foreach (var chunk in Ai.StreamWriteAsync(_instruction, _topic, _cts.Token))
{
_streamBuffer += chunk;
StateHasChanged();
}
}
finally
{
_busyWrite = false;
_cts?.Dispose();
_cts = null;
}
}
void CancelWrite() => _cts?.Cancel();
}
That’s a full working page. No provider‑specific code in the component.
UX notes that help a lot
- Streaming: Users feel speed when text starts to appear fast. The streaming API above is simple and makes a big difference.
- Schema first: Ask the model to return only JSON for anything you plan to parse. Then
TryParse
it and keep the UI stable when JSON is malformed. - Temperature: Use
0.0
for parsing and scoring; use a bit higher (0.5-0.8) for writing. - Guardrails: Always bound output size and keep prompts short. Add “no personal data” reminders in system prompts if needed.
Switching providers without touching components
Your Blazor pages have no provider code. To swap vendors, change only Program.cs and secrets:
- GitHub Models – OpenAI: set
AI:Provider=openai
, changeAI:Model
togpt-4o-mini
, store an OpenAI key - GitHub Models – Azure: set
AI:Provider=azure
, set endpoint + deployment, grant identity access
Deploy checks:
- Don’t expose keys to Blazor WASM
- Use per‑environment secrets
- Monitor token usage before you roll out to prod
Parsing unstructured data: more robust JSON
The earlier example is the minimal version. Here’s a stronger system prompt I use in real apps:
new ChatMessage(ChatRole.System, """
You are a strict JSON extraction worker.
Output ONLY minified JSON for this schema:
{
"name": string | null,
"email": string | null,
"phone": string | null,
"company": string | null
}
Rules:
- Never add comments, markdown, or keys outside the schema.
- If field unknown, use null.
- Normalize phone to E.164 if possible.
""")
Then validate with a try/catch
and fall back to a friendly error if parsing fails.
Sentiment: fast numeric score with a label
Keep it boring and stable:
new ChatMessage(ChatRole.System, "Rate sentiment from -1 to 1 and return {score:number,label:string} with no prose.")
If you need categories (e.g., “praise”, “bug”, “churn risk”), extend the JSON schema and keep temperature near zero.
Content generation with streaming
For writing tasks you want streams and small chunks. The earlier StreamWriteAsync
already does that. Small extras that help:
- Add a max tokens limit to your prompt (“150 words max”).
- Ask for short paragraphs and avoid long intros.
- Post‑process on the client (e.g., convert
\n\n
to<p>
tags).
Using GitHub Models for free development
- Works great in Codespaces and Actions because
GITHUB_TOKEN
is injected - Locally, use a fine‑grained PAT with the models permission
- Endpoint:
https://models.github.ai/inference/
- Model id format:
publisher/name
(for OpenAI models that’sopenai/gpt-4o-mini
, etc.)
When you move to paid usage, switch to OpenAI or Azure by changing only DI and secrets.
Common pitfalls
- Blazor WASM: do not call models from the browser, you’ll leak keys. Put calls in an API or use Blazor Server.
- Rate limits: free dev tiers are generous but not unlimited. Back off on 429 and show a gentle message.
- Non‑JSON: models sometimes add prose. The strict system prompt +
try/catch
solve 95% of cases. - Huge inputs: trim input before sending. For logs/emails, send the last 2-4 KB first.
FAQ: quick answers
Yes. With models that accept vision, you can attach an ImageContent
to a ChatMessage
. Keep images small or pass URLs.
Microsoft.Extensions.AI
supports tool calls. For many apps, a small set of server functions (e.g., search, save note) is enough.
No. For simple chat, parsing, and writing, this abstraction is enough. You can still add SK later if you want planning or agent flows.
Yes. Wrap AiService
and log inputs/outputs with care. Scrub secrets and user data.
Mock IChatClient
and return canned responses. Your components won’t know the difference.
Conclusion: one interface, three providers, zero drama
You now have a Blazor page that parses messy text, scores sentiment, and writes content with streaming – all through one interface. Start on GitHub Models for free, then flip a switch to OpenAI or Azure when you’re ready.
Did you try this in your app? What else should we add to the playground – embeddings, RAG, or tools? Tell me in the comments.