Trace AI Requests with .NET and Ollama
Build a local .NET API that traces and structures Ollama support analysis.
Introduction
30 Second Summary
A support issue can look simple at the front door while taking a winding path behind the scenes. When that path is invisible, every slow or failed response becomes harder to explain.
In this project, you will build a local developer-support triage API with ASP.NET Core and Ollama. You will trace one HTTP request through a local LLM into a validated JSON analysis.
What You'll Build
Your running API turns a 502 deployment issue into an AI classification with a correlation ID that matches the terminal logs.
By the end of this project, you'll have:
- A local support endpoint that classifies an issue into category, urgency, explanation, and suggestedAction fields.
- A traceable request journey where one request ID appears in the response header, response body, middleware logs, and service logs.
- Deliberate failure behavior with 400, 502, 503, and 504 responses that reveal where a request stopped.
- Secret Mission: Add a second endpoint that streams model output to the HTTP client as each update arrives.
Do I need experience with C# or AI development?
Basic programming familiarity is enough for this project. You need no prior experience with C# or machine learning because each request-lifecycle concept is introduced as you use it.
Before We Start
A single support issue gives every layer of this project the same thread to follow. The journey runs from HTTP binding through middleware through dependency injection through Ollama to JSON output.
Set Up the Local API
Your support request cannot travel through an ASP.NET Core API until the local .NET runtime can start a reachable HTTP process.
This step proves the local Ollama model can answer independently. You will finish with a health endpoint that confirms the API is ready.
In this step, get ready to:
- Verify the .NET 10 SDK and local Ollama server.
- Download the Gemma 3 1B model and confirm it can answer a prompt.
- Build the project in Visual Studio Code and call its health endpoint with curl or PowerShell.
Verify your local tools
Each tool proves a different part of the local path. Start with the SDK that builds and runs the API.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Terminal on macOS or PowerShell on Windows. Press Enter.
- Check the installed .NET SDKs by running:
dotnet --info
What does this command prove?
The SDK section lists every installed .NET SDK. Your project needs an entry whose version starts with 10 because its target framework is net10.0.
✔️ I see a .NET 10 SDK
Your machine can build the API with the required SDK. Continue with the Ollama check.
ⓧ I see an older version
The installed SDK cannot build this project yet. Install the .NET 10 SDK before continuing.
- Open the official .NET installation guide for Windows, macOS, or Linux.
- Follow the instructions for installing the .NET 10 SDK.
- Return to your terminal after the installation finishes.
- Repeat the SDK check by running:
dotnet --info
What should you check?
Look in the SDK section again. Continue when you can see an installed version beginning with 10.
ⓧ Command not found
Your terminal cannot find the .NET CLI. Install the .NET 10 SDK to add the runtime and build tools.
- Open the official .NET installation guide for Windows, macOS, or Linux.
- Follow the instructions for installing the .NET 10 SDK.
- Close your current terminal after the installation finishes.
- Open a fresh terminal through system search.
- Repeat the SDK check by running:
dotnet --info
What should you check?
A fresh terminal loads the updated command path. Continue when the SDK section includes a version beginning with 10.
- Check whether the local Ollama server responds by running:
ollama ls
What does this command prove?
This command asks Ollama for its local model list. A returned list proves the CLI can reach the local Ollama server.
You may see an empty model list at this point. The next substep downloads the project model.
Can't reach Ollama?
- Use your operating system search to start the Ollama application.
- Wait for Ollama to finish starting.
- Run the model-list check again.
Get help diagnosing the local server with Why can't the Ollama CLI reach my local server?.
- Check whether curl is available by running:
curl --version
What does this command prove?
A version response confirms that curl is ready to send HTTP requests. Windows learners can use Invoke-RestMethod when curl is unavailable.
✔️ curl shows a version
Your HTTP client is ready. You will use it after the API starts.
ⓧ curl is unavailable on Windows
Use the PowerShell Invoke-RestMethod fallback for the health check. The final check includes the complete command.
Download and test the local model
The API depends on Gemma 3 1B for local inference. Testing it directly separates model problems from API problems.
The first run downloads about 806 MB when the model is missing. This can take several minutes, so a quiet terminal during the download is expected.
- Download and start the gemma3:1b interactive session by running:
ollama run gemma3:1b
What does this command do?
Ollama downloads the model when needed. It then opens an interactive session that sends your prompts to the model running on your computer.
- Type In one sentence, what can cause a 502 error? into the interactive prompt.
- Press Enter to send the prompt.
You should receive a short response from the local model. That response proves local inference works before your API enters the path.
- Leave the interactive session by typing /bye.
Good progress. Your model is downloaded and capable of answering a support question locally.
Build and run the health endpoint
A health endpoint gives you a small first request to trace. It confirms the project can restore dependencies and compile before any AI integration is added.
You created this folder yourself, so it is safe to trust in Visual Studio Code if a workspace prompt appears.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code and press Enter.
- Click File in the top menu.
- Select Open Folder....
- Navigate to your Desktop in the folder dialog.
- Create a folder named LocalAiSupportApi with the dialog's new-folder control.
- Select the LocalAiSupportApi folder.
- Click Open on macOS or Select Folder on Windows.
The Explorer sidebar should now show an empty LocalAiSupportApi workspace.
- Select Yes, I trust the authors if the workspace trust prompt appears.
- Select the New File... control in the Explorer sidebar.
- Enter LocalAiSupportApi.csproj as the file name. Press Enter.
- Add the project configuration by replacing the empty file with this code:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.11.0" />
<PackageReference Include="OllamaSharp" Version="5.5.2" />
</ItemGroup>
</Project>
What does this project file do?
- The web SDK prepares the project to host an ASP.NET Core application.
- The net10.0 target tells .NET which framework to compile against.
- The Microsoft.Extensions.AI package provides the shared AI abstractions used later.
- The OllamaSharp package connects those abstractions to local Ollama.
- Save LocalAiSupportApi.csproj with Cmd+S on macOS or Ctrl+S on Windows.
- Click Terminal in the top menu.
- Select New Terminal.
- Restore the pinned project dependencies by running:
dotnet restore
What does this command do?
The restore reads LocalAiSupportApi.csproj and downloads the required NuGet packages. It resolves Microsoft.Extensions.AI at 10.11.0 and OllamaSharp at 5.5.2.
The command should finish without dependency restore errors. Your package configuration is now valid.
Restore not completing?
- Confirm the terminal path ends with LocalAiSupportApi.
- Compare both package names and version numbers with the project file above.
- Confirm your computer can reach the internet for the package download.
Get help with the restore using Why is dotnet restore failing for my project?.
- Select the New File... control in the Explorer sidebar.
- Enter Program.cs as the file name. Press Enter.
- Add the starter health endpoint by replacing the empty file with this code:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
model = "gemma3:1b"
}));
app.Run("http://localhost:5000");
What does this code do?
- The builder prepares the application services and hosting configuration.
- The Minimal API route handles GET requests sent to /health.
- The route returns the API status with the selected model identifier.
- The final line starts the HTTP server at http://localhost:5000.
- Save Program.cs with Cmd+S on macOS or Ctrl+S on Windows.
- Compile the project by running:
dotnet build
What does this command prove?
The build compiles Program.cs with the restored dependencies. A successful build proves the project file and starter API are valid together.
You should see that the build succeeded without errors. The API is ready to run.
Build not succeeding?
- Confirm Program.cs contains every brace and parenthesis shown above.
- Confirm LocalAiSupportApi.csproj targets net10.0.
- Save both files before running the build again.
Get help with the compiler output using Why is my LocalAiSupportApi project failing to build?.
✔️ Awesome, I've got everything!
- Confirm LocalAiSupportApi.csproj and Program.cs are saved.
ⓧ I'd like to double check the full code
- Compare your LocalAiSupportApi.csproj file with this complete reference:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.11.0" />
<PackageReference Include="OllamaSharp" Version="5.5.2" />
</ItemGroup>
</Project>
What should match?
Check the target framework and both package references character by character. Save the file after correcting any differences.
- Compare your Program.cs file with this complete reference:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
model = "gemma3:1b"
}));
app.Run("http://localhost:5000");
What should match?
Check the route path and response values. Confirm the final line uses http://localhost:5000 before saving the file.
- Start the local ASP.NET Core app by running:
dotnet run
What does this command do?
The command builds the project when needed and starts the web process from source. This terminal stays occupied while the API listens at http://localhost:5000.
- Keep the first terminal running.
- Click Terminal in the top menu.
- Select New Terminal to create a second terminal.
Before you make the request, which status and model values do you expect the health endpoint to return?
✔️ curl is available
- Request the health endpoint with curl by running:
curl http://localhost:5000/health
What does this command check?
curl sends a GET request to the local health endpoint. The response body shows whether the running API reports the expected model.
ⓧ curl is unavailable on Windows
- Request the health endpoint with PowerShell by running:
Invoke-RestMethod -Uri http://localhost:5000/health
What does this command check?
PowerShell sends a GET request and deserializes the returned JSON object. The displayed properties show whether the API reports the expected model.
You should see status set to ok and model set to gemma3:1b.
Health request not working?
- Confirm the first terminal still has the API process running.
- Confirm the request uses http://localhost:5000/health exactly.
- Stop any other local process using port 5000 before starting the API again.
Get help with the local request using Why can't I reach my ASP.NET Core health endpoint?.
That is your first visible win. Your runtime, local model, project dependencies, and HTTP path are ready for the AI request endpoint in the next step.
Send a Request Through the Stack
Your health check proved that ASP.NET Core can serve a response from the local process. The next request must reach Ollama through the application itself.
JSON body binding turns the incoming issue into a C# object. Dependency injection supplies the model client to your analysis service.
Asynchronous I/O lets the route wait for local model inference without blocking the request thread. This creates the first complete path from HTTP request to AI response.
In this step, get ready to:
- Define the incoming support request and the service that calls the local model.
- Register the model client through dependency injection and add the analysis route.
- Send the same issue twice and compare the free-form responses.
Create the request model and analyzer service
The route needs a C# type that matches the incoming JSON body. The property name becomes the bridge between the caller's issue text and your application code.
- Stop the running API from its terminal before changing the project.
- In the Explorer sidebar in Visual Studio Code, create a Models folder inside LocalAiSupportApi.
- Inside Models, create a file named SupportRequest.cs.
- Define the request body by pasting this code into SupportRequest.cs:
namespace LocalAiSupportApi.Models;
public sealed record SupportRequest(string Issue);
What does this model do?
- The SupportRequest record gives ASP.NET Core a target type for JSON body binding.
- The Issue property holds the support problem sent by the caller.
- Save Models/SupportRequest.cs.
- Confirm the request model compiles by running this command in the terminal from earlier:
dotnet build
What does this check prove?
The build confirms that the new record uses valid C# syntax inside the existing project. It catches namespace or punctuation mistakes before the model service depends on this type.
You should see the build complete successfully. The request body now has a concrete C# shape.
Is the request model failing to build?
- Check that SupportRequest.cs sits inside the Models folder.
- Compare the namespace and record declaration with the reference below.
Ask for help with the request model build error.
✔️ Awesome, I've got everything!
Great. Make sure Models/SupportRequest.cs is saved before you continue.
ⓧ I'd like to double check the full code
namespace LocalAiSupportApi.Models;
public sealed record SupportRequest(string Issue);
How to use this reference
Compare the complete file line by line with your saved copy. The namespace must match the folder used by the rest of the project.
The request model carries data into the application. A separate service now owns the model call so the route does not need provider-specific logic.
- In the Explorer sidebar, create a Services folder inside LocalAiSupportApi.
- Inside Services, create a file named SupportAnalyzer.cs.
- Define the model-calling service by pasting this code into SupportAnalyzer.cs:
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(IChatClient chatClient)
{
public async Task<string> AnalyzeRawAsync(string issue)
{
ChatResponse response = await chatClient.GetResponseAsync(issue);
return response.Text;
}
}
What does this service do?
- The constructor receives an IChatClient without creating a provider client itself.
- The AnalyzeRawAsync method sends the issue through GetResponseAsync.
- The method returns response.Text as an ordinary string for the route to expose.
- Save Services/SupportAnalyzer.cs.
- Confirm the analyzer compiles against the installed AI package by running:
dotnet build
What does this build prove?
The build confirms that the project can resolve IChatClient and ChatResponse from Microsoft.Extensions.AI. It also checks the asynchronous method signature.
You should see another successful build. Your project now has a service that can call any registered IChatClient implementation.
Is the analyzer failing to compile?
- Check that SupportAnalyzer.cs contains the Microsoft.Extensions.AI import.
- Confirm LocalAiSupportApi.csproj still contains the pinned Microsoft.Extensions.AI package reference.
- Check that the method name is exactly AnalyzeRawAsync.
Ask for help with the analyzer compilation error.
✔️ Awesome, I've got everything!
Your analyzer service is saved and compiling. Continue to the dependency injection wiring.
ⓧ I'd like to double check the full code
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(IChatClient chatClient)
{
public async Task<string> AnalyzeRawAsync(string issue)
{
ChatResponse response = await chatClient.GetResponseAsync(issue);
return response.Text;
}
}
How to use this reference
Compare this complete service with your saved file. Pay close attention to the namespace and the AnalyzeRawAsync spelling.
Wire the services into the route
The analyzer asks for an interface instead of constructing Ollama directly. The application's service container decides which concrete client satisfies that interface.
Why use IChatClient?
The IChatClient abstraction keeps the service focused on conversation logic. The route remains independent of Ollama's transport details.
The singleton lifetime reuses one provider client across requests. The scoped analyzer gives each HTTP request its own service instance.
- In Program.cs, select the existing line that creates builder.
- Replace that line with the imports and service registrations below:
using LocalAiSupportApi.Models;
using LocalAiSupportApi.Services;
using Microsoft.Extensions.AI;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(_ =>
new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:1b"));
builder.Services.AddScoped<SupportAnalyzer>();
What does this registration do?
- The imports make the request model and analyzer available to Program.cs.
- The singleton registration creates an OllamaApiClient for the local Ollama server and gemma3:1b.
- The scoped registration lets route handlers request a SupportAnalyzer from the service container.
- Save Program.cs.
- Verify the dependency injection registrations by running:
dotnet build
What does this build check?
The build confirms that OllamaApiClient implements IChatClient. It also confirms that the analyzer type is available for registration.
You should see a successful build. The application can now provide the model client whenever SupportAnalyzer requests it.
Are the service registrations failing?
- Check that all four imports sit above the builder declaration.
- Confirm the Ollama address is exactly http://localhost:11434.
- Confirm the model identifier is exactly gemma3:1b.
Ask for help with the dependency injection registration.
A Minimal API route can receive body-bound data and registered services through its parameters. The handler only needs to pass the issue to the analyzer and shape the HTTP response.
- In Program.cs, find the app.Run call near the bottom of the file.
- Add the analysis route directly above that call by pasting this code:
app.MapPost("/api/support/analyze", async (
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context) =>
{
var answer = await analyzer.AnalyzeRawAsync(request.Issue);
return Results.Ok(new
{
requestId = context.TraceIdentifier,
answer
});
});
How does the request move through this route?
- ASP.NET Core binds the incoming JSON body to SupportRequest.
- The service container injects the scoped SupportAnalyzer.
- The handler awaits AnalyzeRawAsync while Ollama generates the response.
- The success response combines context.TraceIdentifier with the free-form answer.
- Save Program.cs.
✔️ Awesome, I've got everything!
The route and dependency injection registrations are in place. Compare the full file only if you want an extra check.
ⓧ I'd like to double check the full code
using LocalAiSupportApi.Models;
using LocalAiSupportApi.Services;
using Microsoft.Extensions.AI;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(_ =>
new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:1b"));
builder.Services.AddScoped<SupportAnalyzer>();
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
model = "gemma3:1b"
}));
app.MapPost("/api/support/analyze", async (
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context) =>
{
var answer = await analyzer.AnalyzeRawAsync(request.Issue);
return Results.Ok(new
{
requestId = context.TraceIdentifier,
answer
});
});
app.Run("http://localhost:5000");
How to use this reference
Compare this complete file with your saved Program.cs. Keep the health route and app.Run call in their original positions around the new POST route.
- Start the updated API from the terminal by running:
dotnet run
What does this command do?
The command builds the changed project and starts the ASP.NET Core process from source. The process listens at the local address already configured in Program.cs.
- Keep the API terminal running while you test the new route.
- Confirm the terminal shows that the application is listening at http://localhost:5000.
Is the API failing to start?
- Check that the earlier API process stopped before you started this version.
- Return to the latest build error if the terminal points to a C# file or line number.
- Confirm Ollama is still running locally before testing the model route.
Ask for help with the API startup failure.
Call the endpoint twice
The running route now crosses every layer in this first vertical slice. A real request can prove that body binding, service resolution, asynchronous model access, and response serialization all work together.
Before you send the first request, what two top-level values do you expect the HTTP response to contain?
curl
- Send the sample deployment issue from a second terminal by running this command:
curl -H "Content-Type: application/json" -d '{"issue":"My deployment succeeds, but the app returns 502 errors."}' http://localhost:5000/api/support/analyze
What does this request send?
The header identifies the body as JSON. The body supplies the issue value that ASP.NET Core binds to SupportRequest.
PowerShell
- Send the sample deployment issue from a second PowerShell terminal by running these commands:
$body = '{"issue":"My deployment succeeds, but the app returns 502 errors."}'
Invoke-RestMethod -Uri 'http://localhost:5000/api/support/analyze' -Method Post -ContentType 'application/json' -Body $body
What does this request send?
The body variable holds the JSON request. Invoke-RestMethod sends it as a POST request and converts the JSON response into a PowerShell object.
You should receive a response containing requestId plus an answer string generated by the local model.
That completes your first end-to-end model request. Your issue now travels from an HTTP client through ASP.NET Core and into the local model.
Before you repeat the request, do you think the second answer will use exactly the same wording and structure?
curl
- Send the same issue a second time by running the command again:
curl -H "Content-Type: application/json" -d '{"issue":"My deployment succeeds, but the app returns 502 errors."}' http://localhost:5000/api/support/analyze
What are you comparing?
This request keeps the issue and route unchanged. That gives you two model responses produced from the same input.
PowerShell
- Send the same issue a second time by running these commands again:
$body = '{"issue":"My deployment succeeds, but the app returns 502 errors."}'
Invoke-RestMethod -Uri 'http://localhost:5000/api/support/analyze' -Method Post -ContentType 'application/json' -Body $body
What are you comparing?
This request keeps the issue and route unchanged. That gives you two model responses produced from the same input.
Compare the two answer values. Their wording or layout can differ because the API currently exposes unconstrained model prose.
Why is raw model text a weak API contract?
A caller cannot reliably extract a category or urgency from prose that may change shape. Even closely matching answers still lack stable fields for application code to consume.
This shortfall is intentional. You have proved the entire request path before tightening the model boundary.
Did the request fail to return an answer?
- Confirm the API terminal is still running at http://localhost:5000.
- Confirm Ollama is still running locally.
- Check that the request uses the exact route /api/support/analyze.
Ask for help tracing the failed request.
Your full request path works from client to local model. Next, you will replace the changing prose with a typed response that callers can depend on.
Return Structured Analysis
Your endpoint now sends a complete request to Ollama and returns the model's answer. The free-form response changes its wording or layout between calls.
A JSON schema gives the model a stable response shape. Typed validation then stops unsupported values from leaking through your API.
In this step, get ready to:
- Define a typed contract for every support analysis.
- Guide the model with a JSON schema before validating its response.
- Return stable analysis fields from the support endpoint.
Define the analysis contract
A C# record describes the four values that every successful analysis must contain. This type becomes the shared contract for schema generation and response validation.
- Switch back to the terminal running the API.
- Press Ctrl+C to stop the current process.
- Create SupportAnalysis.cs inside the Models folder by pasting this code:
namespace LocalAiSupportApi.Models;
public sealed record SupportAnalysis(
string Category,
string Urgency,
string Explanation,
string SuggestedAction);
What does this contract define?
- The SupportAnalysis record gives the model response a concrete C# type.
- The Category and Urgency properties hold the classification.
- The Explanation and SuggestedAction properties make the result useful to the caller.
- Save Models/SupportAnalysis.cs.
- Confirm the new record compiles by running:
dotnet build
What does this check prove?
The build confirms that the new record belongs to the project's namespace. It also confirms that all four property declarations use valid C# syntax.
You should see a successful build with no compiler errors. The typed response contract is ready for the analyzer.
Seeing a compiler error?
- Check that SupportAnalysis.cs sits inside the Models folder.
- Confirm the namespace is LocalAiSupportApi.Models.
- Check that each property name matches the reference exactly.
Help me fix my SupportAnalysis record.
✔️ Awesome, I've got everything!
Your analysis contract is saved and compiles successfully.
ⓧ I'd like to double check the full code
namespace LocalAiSupportApi.Models;
public sealed record SupportAnalysis(
string Category,
string Urgency,
string Explanation,
string SuggestedAction);
How this file fits
This complete file defines the exact result type used for schema generation and typed validation.
Generate and validate structured output
The Microsoft.Extensions.AI abstraction can turn your C# record into a schema for the model. Schema guidance controls the response structure.
Typed deserialization proves that the returned text matches the C# contract. Allowed-value checks add a second boundary for category and urgency.
- Switch back to Services/SupportAnalyzer.cs from the previous step.
- Replace the existing file prefix through the class opening brace with this expanded prefix:
using System.Text.Json;
using LocalAiSupportApi.Models;
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(IChatClient chatClient)
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> AllowedCategories =
new(StringComparer.OrdinalIgnoreCase)
{
"deployment",
"runtime",
"configuration",
"networking",
"unknown"
};
private static readonly HashSet<string> AllowedUrgencies =
new(StringComparer.OrdinalIgnoreCase)
{
"low",
"medium",
"high"
};
What do these shared values control?
- JsonOptions applies the same web JSON conventions during schema generation and deserialization.
- AllowedCategories limits the classification to five supported values.
- AllowedUrgencies limits urgency to three supported levels.
- OrdinalIgnoreCase accepts differences in letter casing without accepting a new value.
- Save Services/SupportAnalyzer.cs.
- Confirm the existing raw method still compiles with the new shared values by running:
dotnet build
What does this intermediate build prove?
This build checks the JSON configuration and both allowed-value sets before the method changes. A successful result keeps the edit loop small.
You should see another successful build. The analyzer now has the rules needed to judge a structured response.
- Replace the complete AnalyzeRawAsync method with the next two connected code chunks.
public async Task<SupportAnalysis> AnalyzeAsync(
string issue,
CancellationToken cancellationToken)
{
ChatMessage[] messages =
[
new(ChatRole.System, "Classify a developer support issue. Return JSON only."),
new(ChatRole.User, issue)
];
ChatOptions options = new()
{
Temperature = 0,
MaxOutputTokens = 240,
ResponseFormat =
ChatResponseFormat.ForJsonSchema<SupportAnalysis>(JsonOptions)
};
ChatResponse response = await chatClient.GetResponseAsync(
messages,
options,
cancellationToken);
How does the model receive the contract?
- The system message assigns the classification task.
- The user message carries the support issue.
- Temperature uses 0 to reduce response variation.
- ForJsonSchema<SupportAnalysis>() derives the required response shape from your record.
- The cancellationToken lets an abandoned HTTP request stop the model call.
- Complete the same method by pasting this continuation directly below the first chunk:
if (string.IsNullOrWhiteSpace(response.Text))
{
throw new JsonException("The model returned an empty response.");
}
var analysis = JsonSerializer.Deserialize<SupportAnalysis>(
response.Text,
JsonOptions) ?? throw new JsonException("The model returned null JSON.");
if (!AllowedCategories.Contains(analysis.Category) ||
!AllowedUrgencies.Contains(analysis.Urgency) ||
string.IsNullOrWhiteSpace(analysis.Explanation) ||
string.IsNullOrWhiteSpace(analysis.SuggestedAction))
{
throw new JsonException("The model returned unsupported values.");
}
return analysis;
}
How is the response validated?
- The empty-text check rejects a response that cannot contain an analysis.
- Deserialize<SupportAnalysis>() converts the model text into your C# record.
- The semantic checks reject unsupported category or urgency values.
- The final checks reject missing explanation or action text.
- Save Services/SupportAnalyzer.cs.
- Verify the complete analyzer compiles by running:
dotnet build
What does the completed build prove?
The successful build confirms that the new method returns SupportAnalysis through every valid path. It also confirms that the schema API accepts your JSON options.
You should see a successful build with no compiler errors. Your model boundary now expects a typed result.
Analyzer failing to compile?
- Confirm System.Text.Json and LocalAiSupportApi.Models appear above the namespace.
- Check that the old AnalyzeRawAsync method has been fully replaced.
- Confirm both connected method chunks sit inside the SupportAnalyzer class.
Help me debug my structured SupportAnalyzer.
✔️ Awesome, I've got everything!
Your analyzer now requests structured output and validates the typed result.
ⓧ I'd like to double check the full code
using System.Text.Json;
using LocalAiSupportApi.Models;
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(IChatClient chatClient)
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> AllowedCategories =
new(StringComparer.OrdinalIgnoreCase)
{
"deployment",
"runtime",
"configuration",
"networking",
"unknown"
};
private static readonly HashSet<string> AllowedUrgencies =
new(StringComparer.OrdinalIgnoreCase)
{
"low",
"medium",
"high"
};
public async Task<SupportAnalysis> AnalyzeAsync(
string issue,
CancellationToken cancellationToken)
{
ChatMessage[] messages =
[
new(ChatRole.System, "Classify a developer support issue. Return JSON only."),
new(ChatRole.User, issue)
];
ChatOptions options = new()
{
Temperature = 0,
MaxOutputTokens = 240,
ResponseFormat =
ChatResponseFormat.ForJsonSchema<SupportAnalysis>(JsonOptions)
};
ChatResponse response = await chatClient.GetResponseAsync(
messages,
options,
cancellationToken);
if (string.IsNullOrWhiteSpace(response.Text))
{
throw new JsonException("The model returned an empty response.");
}
var analysis = JsonSerializer.Deserialize<SupportAnalysis>(
response.Text,
JsonOptions) ?? throw new JsonException("The model returned null JSON.");
if (!AllowedCategories.Contains(analysis.Category) ||
!AllowedUrgencies.Contains(analysis.Urgency) ||
string.IsNullOrWhiteSpace(analysis.Explanation) ||
string.IsNullOrWhiteSpace(analysis.SuggestedAction))
{
throw new JsonException("The model returned unsupported values.");
}
return analysis;
}
}
How this file fits
This complete file contains the shared JSON settings, allowed values, schema-guided request, typed deserialization, and semantic validation.
Return and verify the typed analysis
The endpoint still calls the old raw method. It also needs the request's cancellation token so the asynchronous model call follows the HTTP request lifecycle.
- Switch back to Program.cs from the previous step.
- Replace the complete /api/support/analyze route with this version:
app.MapPost("/api/support/analyze", async (
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context,
CancellationToken cancellationToken) =>
{
var analysis = await analyzer.AnalyzeAsync(request.Issue, cancellationToken);
return Results.Ok(new
{
requestId = context.TraceIdentifier,
analysis
});
});
What changes at the endpoint?
- ASP.NET Core supplies the request cancellation token to the route handler.
- AnalyzeAsync() receives the support issue and cancellation token.
- The response now exposes the typed result under analysis.
- The existing requestId remains available beside the analysis.
- Save Program.cs.
✔️ Awesome, I've got everything!
Your endpoint now returns the typed analysis from AnalyzeAsync().
ⓧ I'd like to double check the full code
using LocalAiSupportApi.Models;
using LocalAiSupportApi.Services;
using Microsoft.Extensions.AI;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(_ =>
new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:1b"));
builder.Services.AddScoped<SupportAnalyzer>();
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
model = "gemma3:1b"
}));
app.MapPost("/api/support/analyze", async (
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context,
CancellationToken cancellationToken) =>
{
var analysis = await analyzer.AnalyzeAsync(request.Issue, cancellationToken);
return Results.Ok(new
{
requestId = context.TraceIdentifier,
analysis
});
});
app.Run("http://localhost:5000");
How this file fits
This complete file preserves the existing service registrations and health route. The updated POST route now returns structured analysis.
- Restart the API from the LocalAiSupportApi folder by running:
dotnet run
What does this command do?
The command builds the updated project and starts its HTTP process. The API becomes reachable again at http://localhost:5000.
Before you resend the issue, which top-level response property do you expect to replace the old free-form answer?
- Switch to the terminal you used for HTTP requests in the previous step.
- Press the Up Arrow key to recall the previous POST request.
- Press Enter to send the recalled request.
The response now exposes requestId beside analysis.
Inside analysis, you should see category, urgency, explanation, and suggestedAction. Every successful response now follows this shape.
Still seeing the answer field?
- Confirm you saved Program.cs before restarting the API.
- Check that the route calls AnalyzeAsync.
- Confirm the anonymous response object contains analysis.
- Restart the API with the run command from above after correcting the route.
Help me find why my endpoint still returns answer.
Strong work. Your API now turns model output into a stable contract that another application can consume.
Next, you will trace one request ID through the response and application logs.
Trace the Request Journey
Your ASP.NET Core endpoint now returns a stable support analysis. However, the response does not reveal which layer ran or how long the journey took.
A correlation ID gives one request a shared identity across the application. You will add middleware that carries this identity from entry to exit.
In this step, get ready to:
- Create request tracing that records the request ID plus elapsed time.
- Add model-call logs without recording the submitted support issue.
- Follow one request ID through the response plus the application logs.
Create the request tracing middleware
Middleware can run before an endpoint receives a request. It can also resume after the endpoint produces a response.
This position lets the tracing layer record the start time. It also lets the same layer record the final status plus elapsed time.
- Return to the terminal running the API.
- Stop the API by pressing Ctrl+C.
- Switch back to Visual Studio Code.
- Create a folder named Middleware inside the LocalAiSupportApi folder using the file sidebar.
- Create RequestTraceMiddleware.cs inside the Middleware folder using the file sidebar.
You should now see Middleware/RequestTraceMiddleware.cs in the project tree.
- Add the request tracing layer to Middleware/RequestTraceMiddleware.cs by pasting this code:
using System.Diagnostics;
namespace LocalAiSupportApi.Middleware;
public sealed class RequestTraceMiddleware(
RequestDelegate next,
ILogger<RequestTraceMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var requestId = context.TraceIdentifier;
var stopwatch = Stopwatch.StartNew();
context.Response.Headers["X-Request-ID"] = requestId;
using var scope = logger.BeginScope("RequestId: {RequestId}", requestId);
logger.LogInformation("Request started {Method} {Path}", context.Request.Method, context.Request.Path);
try { await next(context); }
finally
{
stopwatch.Stop();
logger.LogInformation("Request completed with status {StatusCode} in {ElapsedMilliseconds} ms", context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
}
}
}
What Does This Middleware Do?
- The TraceIdentifier value becomes the correlation ID for the current request.
- The X-Request-ID response header exposes the correlation ID to the HTTP client.
- The logging scope attaches the correlation ID to logs created during the request.
- The finally block records the final status plus elapsed time after downstream code finishes.
- Save Middleware/RequestTraceMiddleware.cs.
- Confirm the new middleware compiles by running:
dotnet build
What Does This Check Prove?
The build confirms that the middleware can use the request pipeline plus the stopwatch. It also confirms that the file belongs to the project.
You should see a successful build with no compilation errors. The tracing layer is ready to join the request pipeline.
Is the Middleware Failing to Build?
- Confirm the file path is Middleware/RequestTraceMiddleware.cs inside LocalAiSupportApi.
- Check that using System.Diagnostics; remains at the top of the file.
- Compare the braces with the full-file reference below.
Help me fix my request tracing middleware build error.
✔️ Awesome, I've got everything!
Your middleware compiles successfully. Make sure RequestTraceMiddleware.cs is saved before continuing.
ⓧ I'd like to double check the full code
using System.Diagnostics;
namespace LocalAiSupportApi.Middleware;
public sealed class RequestTraceMiddleware(
RequestDelegate next,
ILogger<RequestTraceMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var requestId = context.TraceIdentifier;
var stopwatch = Stopwatch.StartNew();
context.Response.Headers["X-Request-ID"] = requestId;
using var scope = logger.BeginScope("RequestId: {RequestId}", requestId);
logger.LogInformation("Request started {Method} {Path}", context.Request.Method, context.Request.Path);
try { await next(context); }
finally
{
stopwatch.Stop();
logger.LogInformation("Request completed with status {StatusCode} in {ElapsedMilliseconds} ms", context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
}
}
}
What Should Match?
Compare the namespace plus the InvokeAsync method with your file. The response header assignment must appear before next(context) runs.
Add model-call logs
The middleware describes the outer request journey. The scoped service needs its own logger to show when the Ollama call begins.
The completion log records only the validated category plus urgency. The submitted support issue stays out of the logs.
- In Services/SupportAnalyzer.cs, find public sealed class SupportAnalyzer(IChatClient chatClient).
- Select from that class declaration through the closing brace of the AllowedUrgencies declaration.
- Replace the selected section with this code:
public sealed class SupportAnalyzer(
IChatClient chatClient,
ILogger<SupportAnalyzer> logger)
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> AllowedCategories =
new(StringComparer.OrdinalIgnoreCase)
{
"deployment", "runtime", "configuration", "networking", "unknown"
};
private static readonly HashSet<string> AllowedUrgencies =
new(StringComparer.OrdinalIgnoreCase) { "low", "medium", "high" };
Why Inject a Logger Here?
Dependency injection supplies ILogger<SupportAnalyzer> when it creates the scoped service. The logger can participate in the request scope opened by the middleware.
- Save Services/SupportAnalyzer.cs.
- Confirm the logger injection compiles by running:
dotnet build
What Does This Build Check?
The build verifies that dependency injection can construct SupportAnalyzer with both required services.
You should see another successful build. The service is ready to write scoped model-call logs.
Seeing a Logger Compilation Error?
- Confirm the constructor still includes IChatClient chatClient.
- Confirm the constructor includes ILogger<SupportAnalyzer> logger.
- Check that the class opening brace remains below the constructor parameters.
Help me debug the logger injection.
- In Services/SupportAnalyzer.cs, find the method beginning with public async Task<SupportAnalysis> AnalyzeAsync(.
- Select the entire AnalyzeAsync method through its closing brace.
- Replace the selected method with this traced version:
public async Task<SupportAnalysis> AnalyzeAsync(string issue, CancellationToken cancellationToken)
{
logger.LogInformation("Calling the local Ollama model");
ChatMessage[] messages =
[
new(ChatRole.System, "Classify a developer support issue. Return JSON only."),
new(ChatRole.User, issue)
];
ChatOptions options = new()
{
Temperature = 0,
MaxOutputTokens = 240,
ResponseFormat = ChatResponseFormat.ForJsonSchema<SupportAnalysis>(JsonOptions)
};
ChatResponse response = await chatClient.GetResponseAsync(messages, options, cancellationToken);
if (string.IsNullOrWhiteSpace(response.Text)) throw new JsonException("The model returned an empty response.");
var analysis = JsonSerializer.Deserialize<SupportAnalysis>(response.Text, JsonOptions) ?? throw new JsonException("The model returned null JSON.");
if (!AllowedCategories.Contains(analysis.Category) || !AllowedUrgencies.Contains(analysis.Urgency) || string.IsNullOrWhiteSpace(analysis.Explanation) || string.IsNullOrWhiteSpace(analysis.SuggestedAction)) throw new JsonException("The model returned unsupported values.");
logger.LogInformation("Ollama completed with category {Category} and urgency {Urgency}", analysis.Category, analysis.Urgency);
return analysis;
}
What Do the Service Logs Reveal?
- The first message marks the moment the request crosses into local model inference.
- The completion message runs after deserialization plus semantic validation succeed.
- The structured placeholders record the accepted category plus urgency.
- The issue value only enters the user message.
- Save Services/SupportAnalyzer.cs.
- Verify the traced service compiles by running:
dotnet build
What Does the Final Service Build Prove?
A successful build confirms that both log messages use valid structured placeholders. It also confirms that the existing model-call path remains intact.
You should see a successful build. Your service can now reveal the model boundary without copying the submitted issue.
Is the Completion Log Breaking the Build?
- Place the completion log after the validation condition.
- Keep analysis.Category before analysis.Urgency in the logging arguments.
- Keep return analysis; below the completion log.
Help me fix the structured logging call.
✔️ Awesome, I've got everything!
Your service now logs the start plus validated completion of each model call. Make sure Services/SupportAnalyzer.cs is saved.
ⓧ I'd like to double check the full code
using System.Text.Json;
using LocalAiSupportApi.Models;
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(
IChatClient chatClient,
ILogger<SupportAnalyzer> logger)
{
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> AllowedCategories =
new(StringComparer.OrdinalIgnoreCase)
{
"deployment", "runtime", "configuration", "networking", "unknown"
};
private static readonly HashSet<string> AllowedUrgencies =
new(StringComparer.OrdinalIgnoreCase) { "low", "medium", "high" };
public async Task<SupportAnalysis> AnalyzeAsync(string issue, CancellationToken cancellationToken)
{
logger.LogInformation("Calling the local Ollama model");
ChatMessage[] messages =
[
new(ChatRole.System, "Classify a developer support issue. Return JSON only."),
new(ChatRole.User, issue)
];
ChatOptions options = new()
{
Temperature = 0,
MaxOutputTokens = 240,
ResponseFormat = ChatResponseFormat.ForJsonSchema<SupportAnalysis>(JsonOptions)
};
ChatResponse response = await chatClient.GetResponseAsync(messages, options, cancellationToken);
if (string.IsNullOrWhiteSpace(response.Text)) throw new JsonException("The model returned an empty response.");
var analysis = JsonSerializer.Deserialize<SupportAnalysis>(response.Text, JsonOptions) ?? throw new JsonException("The model returned null JSON.");
if (!AllowedCategories.Contains(analysis.Category) || !AllowedUrgencies.Contains(analysis.Urgency) || string.IsNullOrWhiteSpace(analysis.Explanation) || string.IsNullOrWhiteSpace(analysis.SuggestedAction)) throw new JsonException("The model returned unsupported values.");
logger.LogInformation("Ollama completed with category {Category} and urgency {Urgency}", analysis.Category, analysis.Urgency);
return analysis;
}
}
What Should Match?
Compare the injected logger plus both logging calls with your file. The submitted issue must only appear in the user message.
Register and follow the request trace
ASP.NET Core runs middleware in registration order. Placing the tracing middleware before the routes lets its scope surround endpoint execution plus the asynchronous model call.
- In Program.cs, select from the first using line through var app = builder.Build();.
- Replace the selected section with this registered pipeline:
using LocalAiSupportApi.Middleware;
using LocalAiSupportApi.Models;
using LocalAiSupportApi.Services;
using Microsoft.Extensions.AI;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(_ =>
new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:1b"));
builder.Services.AddScoped<SupportAnalyzer>();
var app = builder.Build();
app.UseMiddleware<RequestTraceMiddleware>();
How Does This Connect the Trace?
- The middleware namespace makes RequestTraceMiddleware available to Program.cs.
- The UseMiddleware registration places tracing before both routes.
- The logging scope remains active while SupportAnalyzer performs its asynchronous model call.
- Save Program.cs.
- Restart the traced API by running:
dotnet run
What Does This Run Activate?
The running process now wraps every request with RequestTraceMiddleware. Keep this terminal running so you can inspect its logs.
You should see the API report that it is listening at http://localhost:5000.
Does the API Stop During Startup?
- Confirm the middleware file uses the LocalAiSupportApi.Middleware namespace.
- Confirm Program.cs imports the same namespace.
- Confirm app.UseMiddleware<RequestTraceMiddleware>(); appears after builder.Build().
Help me debug the middleware registration.
✔️ Awesome, I've got everything!
Your traced API is running at http://localhost:5000.
ⓧ I'd like to double check the full code
using LocalAiSupportApi.Middleware;
using LocalAiSupportApi.Models;
using LocalAiSupportApi.Services;
using Microsoft.Extensions.AI;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(_ =>
new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:1b"));
builder.Services.AddScoped<SupportAnalyzer>();
var app = builder.Build();
app.UseMiddleware<RequestTraceMiddleware>();
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
model = "gemma3:1b"
}));
app.MapPost("/api/support/analyze", async (
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context,
CancellationToken cancellationToken) =>
{
var analysis = await analyzer.AnalyzeAsync(request.Issue, cancellationToken);
return Results.Ok(new
{
requestId = context.TraceIdentifier,
analysis
});
});
app.Run("http://localhost:5000");
What Should Match?
Compare the middleware import plus its registration position with your file. The structured endpoint remains unchanged below the health route.
- Create a second terminal using the new-terminal control in Visual Studio Code's terminal panel.
Before you send the request, where do you expect the same request ID to appear?
macOS
- Send the sample issue while printing the response headers by running:
curl -D - -H "Content-Type: application/json" -d '{"issue":"My deployment succeeds, but the app returns 502 errors."}' http://localhost:5000/api/support/analyze
What Does This Command Expose?
- The -D - option prints the received response headers.
- The -H option marks the request body as JSON.
- The -d option sends the support issue in a POST request.
You should see an X-Request-ID response header. The JSON body should contain the same value in requestId.
Windows
- Send the sample issue while capturing the response headers by running:
$response = Invoke-RestMethod -Uri "http://localhost:5000/api/support/analyze" -Method Post -ContentType "application/json" -Body '{"issue":"My deployment succeeds, but the app returns 502 errors."}' -ResponseHeadersVariable "Headers"
$response
$Headers["X-Request-ID"]
What Does This Command Expose?
- The response body is stored in $response.
- The -ResponseHeadersVariable parameter stores the received headers in $Headers.
- The final line prints the X-Request-ID header value.
You should see a structured response containing requestId. The final output should show the same request ID.
Linux
- Send the sample issue while printing the response headers by running:
curl -D - -H "Content-Type: application/json" -d '{"issue":"My deployment succeeds, but the app returns 502 errors."}' http://localhost:5000/api/support/analyze
What Does This Command Expose?
- The -D - option prints the received response headers.
- The -H option marks the request body as JSON.
- The -d option sends the support issue in a POST request.
You should see an X-Request-ID response header. The JSON body should contain the same value in requestId.
- Switch back to the terminal running the API.
- Locate the request start log.
- Locate the model-call start log.
- Locate the model-call completion log.
- Locate the request completion log.
- Compare the scoped request ID with the response header.
- Compare the scoped request ID with the JSON body.
You should find one matching identifier across the response plus every request log. The completion log should also show the final status code plus elapsed milliseconds.
What Did the Trace Prove?
The request ID survives asynchronous work because the logging scope surrounds the downstream pipeline. Logs from SupportAnalyzer inherit that request context.
The support issue stays out of the logs. You can trace execution without copying user input into observability data.
Do the IDs Fail to Line Up?
- Confirm the response header uses context.TraceIdentifier.
- Confirm the endpoint still returns context.TraceIdentifier as requestId.
- Send one request at a time to keep separate traces from overlapping.
Help me trace a mismatched request ID.
That is a strong observability win. One identifier now connects the HTTP response to every layer that handled the request.
Your API now exposes the full request journey from HTTP entry through local inference. Next, you will turn input failures plus model failures into deliberate HTTP responses.
Handle Failures Explicitly
Your traced ASP.NET Core endpoint now carries one request ID from entry to response. Its success path exposes timing across the middleware and service logs.
A production request also needs predictable behavior when the model boundary fails. This step converts each known boundary into an intentional HTTP response.
Blank input stops before the model call. Timeouts and unusable responses reach the caller with specific status codes.
In this step, get ready to:
- Reject blank support issues with a 400 Problem Details response.
- Limit each model call to 120 seconds with linked cancellation.
- Map timeout, validation, and connection failures to deliberate status codes.
Reject blank support issues
A blank issue contains nothing useful for the model to classify. The endpoint should reject it before spending time on local inference.
What are Problem Details?
Problem Details gives an API error a consistent shape. Your response includes a status code, a title, a detail message, and the same request ID used by the tracing middleware.
- Stop the running API in its terminal before editing Program.cs.
- Return to Program.cs in Visual Studio Code.
- Find the current inline endpoint block:
app.MapPost("/api/support/analyze", async (
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context,
CancellationToken cancellationToken) =>
{
var analysis = await analyzer.AnalyzeAsync(request.Issue, cancellationToken);
return Results.Ok(new
{
requestId = context.TraceIdentifier,
analysis
});
});
Why replace the inline endpoint?
The inline handler currently sends every issue directly to the model. A named handler gives the validation, timeout, and exception mappings one clear boundary.
- Replace the inline endpoint block with this route mapping:
app.MapPost("/api/support/analyze", AnalyzeSupportAsync);
What does this mapping do?
The route still accepts requests at the same path. ASP.NET Core now delegates each request to AnalyzeSupportAsync.
- Add the initial AnalyzeSupportAsync method below app.Run("http://localhost:5000");:
static async Task<IResult> AnalyzeSupportAsync(
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context,
CancellationToken requestAborted)
{
var extensions = new Dictionary<string, object?>
{
["requestId"] = context.TraceIdentifier
};
if (string.IsNullOrWhiteSpace(request.Issue))
{
return Results.Problem("Provide a non-empty support issue.", statusCode: StatusCodes.Status400BadRequest, title: "Invalid support request", extensions: extensions);
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
timeout.CancelAfter(TimeSpan.FromSeconds(120));
var analysis = await analyzer.AnalyzeAsync(request.Issue, timeout.Token);
return Results.Ok(new { requestId = context.TraceIdentifier, analysis });
}
What does this handler do?
- The extensions dictionary carries HttpContext.TraceIdentifier into every Problem Details response.
- The blank check returns 400 before AnalyzeAsync can call the model.
- The linked cancellation token reacts to a disconnected client through requestAborted.
- The server timer also cancels the model call after 120 seconds.
- Save Program.cs.
- Start the updated API by running:
dotnet run
What does this command do?
The dotnet run command builds the changed project before starting it. Your API continues listening at http://localhost:5000.
Before you send the blank issue, what status code do you expect the endpoint to return?
curl
- Send the blank issue from a separate terminal by running:
curl -H "Content-Type: application/json" -d '{"issue":""}' http://localhost:5000/api/support/analyze
What does this request test?
The request supplies an empty issue value to the same endpoint used for valid requests. The response reveals whether validation runs before local inference.
PowerShell fallback
- Send the blank issue from a separate PowerShell terminal by running:
try {
Invoke-RestMethod -Uri http://localhost:5000/api/support/analyze -Method Post -Body '{"issue":""}' -ContentType "application/json"
}
catch {
$_.ErrorDetails.Message
}
What does this request test?
PowerShell reports a non-success response through its error path. The catch block prints the Problem Details body returned by your API.
You should see a 400 response with Invalid support request. The response also contains a requestId that matches the X-Request-ID header and middleware log scope.
Good. Blank input now stops at the API boundary without waking the local model.
Still getting a successful response?
- Confirm the request body contains an empty string for issue.
- Check that the inline route was replaced with the AnalyzeSupportAsync mapping.
- Confirm the running process restarted after you saved Program.cs.
Why is my blank support issue still reaching the model?
Map model failures to HTTP responses
Input validation protects the model boundary from bad requests. Exception mapping protects callers when local inference times out, returns unusable content, or loses its connection.
- Stop the running API in its terminal.
- Return to the first using directive in Program.cs.
- Find this line:
using LocalAiSupportApi.Middleware;
Why locate this line?
The endpoint needs the JsonException type for invalid model output. Its namespace belongs with the other using directives.
- Add the JSON namespace above the existing line so the top of the file looks like this:
using System.Text.Json;
using LocalAiSupportApi.Middleware;
What does this import enable?
The import lets the endpoint catch JsonException directly. That exception already represents empty, null, or semantically unsupported model output in SupportAnalyzer.
- Scroll to the final lines of AnalyzeSupportAsync.
- Find this success path and closing brace:
var analysis = await analyzer.AnalyzeAsync(request.Issue, timeout.Token);
return Results.Ok(new { requestId = context.TraceIdentifier, analysis });
}
What does the current path cover?
The current path passes the linked token into the analyzer. It still allows timeout, parsing, and connection exceptions to escape as unexplained server failures.
- Replace those final lines with this guarded success path:
try
{
var analysis = await analyzer.AnalyzeAsync(request.Issue, timeout.Token);
return Results.Ok(new { requestId = context.TraceIdentifier, analysis });
}
catch (OperationCanceledException) when (!requestAborted.IsCancellationRequested)
{
return Results.Problem("The local model did not finish within 120 seconds.", statusCode: StatusCodes.Status504GatewayTimeout, title: "Model timeout", extensions: extensions);
}
catch (JsonException)
{
return Results.Problem("The model response did not satisfy the support-analysis contract.", statusCode: StatusCodes.Status502BadGateway, title: "Invalid model response", extensions: extensions);
}
catch (HttpRequestException)
{
return Results.Problem("The API could not reach Ollama at http://localhost:11434.", statusCode: StatusCodes.Status503ServiceUnavailable, title: "Ollama unavailable", extensions: extensions);
}
}
How are failures mapped?
- A server timeout raises OperationCanceledException while the original request remains connected. The endpoint maps that condition to 504.
- An invalid response raises JsonException. The endpoint maps that model-boundary failure to 502.
- A connection failure raises HttpRequestException. The endpoint maps an unavailable local Ollama service to 503.
- Every error reuses extensions. The caller keeps the same correlation ID on both successful and failed requests.
- Save Program.cs.
- Return to Services/SupportAnalyzer.cs.
- Replace the file contents with this failure-aware analyzer:
using System.Text.Json;
using LocalAiSupportApi.Models;
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(
IChatClient chatClient,
ILogger<SupportAnalyzer> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> AllowedCategories = new(StringComparer.OrdinalIgnoreCase) { "deployment", "runtime", "configuration", "networking", "unknown" };
private static readonly HashSet<string> AllowedUrgencies = new(StringComparer.OrdinalIgnoreCase) { "low", "medium", "high" };
public async Task<SupportAnalysis> AnalyzeAsync(string issue, CancellationToken cancellationToken)
{
logger.LogInformation("Calling the local Ollama model");
ChatMessage[] messages = [new(ChatRole.System, "Classify a developer support issue. Category must be deployment, runtime, configuration, networking, or unknown. Urgency must be low, medium, or high. Explain the likely cause and give one concrete suggested action. Return only the fields required by the supplied JSON schema."), new(ChatRole.User, issue)];
ChatOptions options = new() { Temperature = 0, MaxOutputTokens = 240, ResponseFormat = ChatResponseFormat.ForJsonSchema<SupportAnalysis>(JsonOptions) };
ChatResponse response = await chatClient.GetResponseAsync(messages, options, cancellationToken);
if (string.IsNullOrWhiteSpace(response.Text)) throw new JsonException("The model returned an empty response.");
var analysis = JsonSerializer.Deserialize<SupportAnalysis>(response.Text, JsonOptions) ?? throw new JsonException("The model returned null JSON.");
if (!AllowedCategories.Contains(analysis.Category) || !AllowedUrgencies.Contains(analysis.Urgency) || string.IsNullOrWhiteSpace(analysis.Explanation) || string.IsNullOrWhiteSpace(analysis.SuggestedAction)) throw new JsonException("The model returned unsupported values.");
logger.LogInformation("Ollama completed with category {Category} and urgency {Urgency}", analysis.Category, analysis.Urgency);
return analysis;
}
}
How does the analyzer support failure handling?
- The system message limits category and urgency values before the model responds.
- The schema asks for the same four fields represented by SupportAnalysis.
- The semantic checks throw JsonException when the model returns empty or unsupported content.
- The supplied cancellationToken reaches GetResponseAsync without being replaced.
- Save Services/SupportAnalyzer.cs.
- Check the failure-aware code compiles by running:
dotnet build
What does this build prove?
The dotnet build command checks the new handler signature, exception types, and analyzer call. A successful build proves the full cancellation path compiles.
You should see a successful build with no compilation errors.
Seeing a build error?
- Confirm using System.Text.Json; is the first line in Program.cs.
- Check that the old inline route block was removed completely.
- Confirm every catch block remains inside AnalyzeSupportAsync.
Why does my failure-aware ASP.NET Core endpoint fail to build?
Use these tabs to confirm your project matches the complete failure-aware version.
✔️ Awesome, I've got everything!
Your build succeeds. Double-check that every changed file is saved before the final request.
ⓧ I'd like to double check the full code
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" Version="10.11.0" />
<PackageReference Include="OllamaSharp" Version="5.5.2" />
</ItemGroup>
</Project>
using System.Diagnostics;
namespace LocalAiSupportApi.Middleware;
public sealed class RequestTraceMiddleware(RequestDelegate next, ILogger<RequestTraceMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var requestId = context.TraceIdentifier;
var stopwatch = Stopwatch.StartNew();
context.Response.Headers["X-Request-ID"] = requestId;
using var scope = logger.BeginScope("RequestId: {RequestId}", requestId);
logger.LogInformation("Request started {Method} {Path}", context.Request.Method, context.Request.Path);
try { await next(context); }
finally { stopwatch.Stop(); logger.LogInformation("Request completed with status {StatusCode} in {ElapsedMilliseconds} ms", context.Response.StatusCode, stopwatch.ElapsedMilliseconds); }
}
}
namespace LocalAiSupportApi.Models;
public sealed record SupportAnalysis(
string Category,
string Urgency,
string Explanation,
string SuggestedAction);
namespace LocalAiSupportApi.Models;
public sealed record SupportRequest(string Issue);
using System.Text.Json;
using LocalAiSupportApi.Middleware;
using LocalAiSupportApi.Models;
using LocalAiSupportApi.Services;
using Microsoft.Extensions.AI;
using OllamaSharp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IChatClient>(_ =>
new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:1b"));
builder.Services.AddScoped<SupportAnalyzer>();
var app = builder.Build();
app.UseMiddleware<RequestTraceMiddleware>();
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
model = "gemma3:1b"
}));
app.MapPost("/api/support/analyze", AnalyzeSupportAsync);
app.Run("http://localhost:5000");
static async Task<IResult> AnalyzeSupportAsync(
SupportRequest request,
SupportAnalyzer analyzer,
HttpContext context,
CancellationToken requestAborted)
{
var extensions = new Dictionary<string, object?>
{
["requestId"] = context.TraceIdentifier
};
if (string.IsNullOrWhiteSpace(request.Issue))
{
return Results.Problem("Provide a non-empty support issue.", statusCode: StatusCodes.Status400BadRequest, title: "Invalid support request", extensions: extensions);
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
timeout.CancelAfter(TimeSpan.FromSeconds(120));
try
{
var analysis = await analyzer.AnalyzeAsync(request.Issue, timeout.Token);
return Results.Ok(new { requestId = context.TraceIdentifier, analysis });
}
catch (OperationCanceledException) when (!requestAborted.IsCancellationRequested)
{
return Results.Problem("The local model did not finish within 120 seconds.", statusCode: StatusCodes.Status504GatewayTimeout, title: "Model timeout", extensions: extensions);
}
catch (JsonException)
{
return Results.Problem("The model response did not satisfy the support-analysis contract.", statusCode: StatusCodes.Status502BadGateway, title: "Invalid model response", extensions: extensions);
}
catch (HttpRequestException)
{
return Results.Problem("The API could not reach Ollama at http://localhost:11434.", statusCode: StatusCodes.Status503ServiceUnavailable, title: "Ollama unavailable", extensions: extensions);
}
}
using System.Text.Json;
using LocalAiSupportApi.Models;
using Microsoft.Extensions.AI;
namespace LocalAiSupportApi.Services;
public sealed class SupportAnalyzer(
IChatClient chatClient,
ILogger<SupportAnalyzer> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly HashSet<string> AllowedCategories = new(StringComparer.OrdinalIgnoreCase) { "deployment", "runtime", "configuration", "networking", "unknown" };
private static readonly HashSet<string> AllowedUrgencies = new(StringComparer.OrdinalIgnoreCase) { "low", "medium", "high" };
public async Task<SupportAnalysis> AnalyzeAsync(string issue, CancellationToken cancellationToken)
{
logger.LogInformation("Calling the local Ollama model");
ChatMessage[] messages = [new(ChatRole.System, "Classify a developer support issue. Category must be deployment, runtime, configuration, networking, or unknown. Urgency must be low, medium, or high. Explain the likely cause and give one concrete suggested action. Return only the fields required by the supplied JSON schema."), new(ChatRole.User, issue)];
ChatOptions options = new() { Temperature = 0, MaxOutputTokens = 240, ResponseFormat = ChatResponseFormat.ForJsonSchema<SupportAnalysis>(JsonOptions) };
ChatResponse response = await chatClient.GetResponseAsync(messages, options, cancellationToken);
if (string.IsNullOrWhiteSpace(response.Text)) throw new JsonException("The model returned an empty response.");
var analysis = JsonSerializer.Deserialize<SupportAnalysis>(response.Text, JsonOptions) ?? throw new JsonException("The model returned null JSON.");
if (!AllowedCategories.Contains(analysis.Category) || !AllowedUrgencies.Contains(analysis.Urgency) || string.IsNullOrWhiteSpace(analysis.Explanation) || string.IsNullOrWhiteSpace(analysis.SuggestedAction)) throw new JsonException("The model returned unsupported values.");
logger.LogInformation("Ollama completed with category {Category} and urgency {Urgency}", analysis.Category, analysis.Urgency);
return analysis;
}
}
Verify the failure-aware endpoint
The final check uses a valid issue after all failure mappings are in place. This proves the guarded path still reaches the model and returns structured analysis.
- Start the completed API by running:
dotnet run
What does this final run check?
The command starts the version containing input validation, linked cancellation, and exception mapping. The normal endpoint remains available at the same local address.
Before you send the valid request, do you expect the new guards to change the successful response shape?
curl
- Send the sample deployment issue from a separate terminal by running:
curl -H "Content-Type: application/json" -d '{"issue":"My deployment succeeds, but the app returns 502 errors."}' http://localhost:5000/api/support/analyze
What does this request prove?
The request exercises the success path through middleware, binding, dependency injection, asynchronous model I/O, validation, and serialization. The linked token reaches the model call through timeout.Token.
PowerShell fallback
- Send the sample deployment issue from a separate PowerShell terminal by running:
Invoke-RestMethod -Uri http://localhost:5000/api/support/analyze -Method Post -Body '{"issue":"My deployment succeeds, but the app returns 502 errors."}' -ContentType "application/json"
What does this request prove?
PowerShell sends the same valid issue and deserializes the successful response. The endpoint still returns the structured analysis after the new guards are applied.
You should see a requestId plus an analysis object containing category, urgency, explanation, and suggestedAction.
That completes the safety boundary. Your API now rejects bad input, limits model work, preserves correlation IDs, and reports known failures deliberately.
Valid request no longer succeeds?
- Confirm Ollama is still running locally.
- Check that gemma3:1b remains available to the local service.
- Compare the terminal status code with the mapped 502, 503, and 504 paths to identify the failing boundary.
Why is my valid support request returning a mapped error response?
Secret mission
Stream the Model Response
Your structured endpoint waits for the complete model response before sending anything back. In this Secret Mission, you will add a streaming endpoint that forwards each local model update to the HTTP client as it arrives.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local resources running, pause them for later, or delete them entirely. This project uses no cloud resources. Local Ollama execution has no service fees.
Resources you used:
- The running LocalAiSupportApi process at http://localhost:5000.
- The LocalAiSupportApi project folder with its build output.
- The gemma3:1b model, which uses about 806 MB of local storage.
Keep everything running
No action is needed. Choose this option if you are still testing the structured analysis endpoint or streaming responses.
- Leave the local API process running at http://localhost:5000.
- Retain the LocalAiSupportApi folder for future .NET experiments.
- Keep the gemma3:1b model available for future local AI projects.
Your shared NuGet package cache also stays available for other .NET projects.
The local model continues to use about 806 MB of storage. Keeping it creates no service charges.
Pause - I'll come back to this later
Shut down the running API process to free local memory. Your project files and model remain ready for later.
- Switch back to the terminal running the LocalAiSupportApi app.
- Press Ctrl+C to stop the API process.
That is the local process safely paused. Your project folder and model remain available for the next session.
Delete - I don't want to use this again
Remove the local API process, project folder, and model to reclaim their storage. Deleting these resources is permanent, but it affects only this local project and the selected model.
- Return to the terminal running the LocalAiSupportApi app.
- Press Ctrl+C to stop the API process.
The API process is now stopped. Its localhost endpoint no longer responds.
Remove the project files:
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Finder on macOS or File Explorer on Windows.
- Press Enter to open the selected file manager.
- Enter LocalAiSupportApi into the file manager search box.
- Select the folder named LocalAiSupportApi.
- Move the selected folder into your operating system's deleted-files area.
- Confirm that LocalAiSupportApi no longer appears in the file manager search results.
Remove the local model:
The model is stored separately from the project folder. Removing the folder does not reclaim the model's 806 MB of storage.
- List the installed models by running this command:
ollama ls
What does this command do?
The ollama ls command lists the models stored by Ollama on your computer. The row for gemma3:1b provides the exact model name needed for removal.
- Find the row named gemma3:1b.
- Start the removal line with Ollama's documented ollama rm subcommand.
- Append the model name shown in the gemma3:1b row.
- Press Enter to remove the selected model.
- Confirm the model was removed by listing the installed models again:
ollama ls
What should I see?
The gemma3:1b row should be absent from the new list. That confirms you reclaimed the model's local storage.
Still see the model?
Check that the model name after ollama rm matches the name shown by ollama ls exactly.
Ask for help with the removal command.
Nice Work!
Nice Work!
Excellent work! You built a local ASP.NET Core API that sends support issues to Ollama. Your API now controls the full journey from HTTP input to local model output.
You've learned how to:
- Build a local ASP.NET Core Minimal API that turns a JSON support issue into an Ollama analysis through dependency injection.
- Use a JSON schema to turn unpredictable model prose into a typed SupportAnalysis response. Validate each category against an allowed set. Apply the same check to urgency. Reject empty explanatory fields before they reach the caller.
- Trace one correlation ID from middleware to service logs. Carry the same ID into the response header and JSON body. Propagate cancellation to Ollama. Enforce a server timeout. Map expected failures to deliberate HTTP responses.
- Secret Mission: Use IAsyncEnumerable to stream model output so the HTTP client receives each update as Ollama produces it.
Ready to quiz yourself?