diff --git a/.devcontainer/.net/devcontainer.json b/.devcontainer/.net/devcontainer.json
index 3ded3c2..a349455 100644
--- a/.devcontainer/.net/devcontainer.json
+++ b/.devcontainer/.net/devcontainer.json
@@ -1,7 +1,7 @@
{
"name": ".NET Aspire",
- // Base .NET 9 image. Alternatively a Dockerfile/compose could be used.
- "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm",
+ // Base .NET 10 image. Alternatively a Dockerfile/compose could be used.
+ "image": "mcr.microsoft.com/devcontainers/dotnet:10.0-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:3": {},
"ghcr.io/devcontainers/features/powershell:2": {},
diff --git a/.vscode/launch.json b/.vscode/launch.json
index d16d4e2..c35f75b 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -12,7 +12,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build", // Name of your build task in tasks.json
- "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll",
+ "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll",
"args": ["--environment", "Ollama"],
"cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost",
"stopAtEntry": false,
@@ -23,7 +23,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build", // Name of your build task in tasks.json
- "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll",
+ "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll",
"args": ["--environment", "GitHubModels"],
"cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost",
"stopAtEntry": false,
@@ -34,7 +34,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build", // Name of your build task in tasks.json
- "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll",
+ "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll",
"args": ["--environment", "FoundryLocal"],
"cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost",
"stopAtEntry": false,
@@ -45,7 +45,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build", // Name of your build task in tasks.json
- "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll",
+ "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll",
"args": ["--environment", "AzureOpenAI"],
"cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost",
"stopAtEntry": false,
diff --git a/README.md b/README.md
index e683e1a..f2c19af 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ Chat Agent WeatherTools
### Prerequisites
-- .NET 9.0 SDK or later
+- .NET 10.0 SDK or later
- Docker Desktop (for Aspire)
- Visual Studio Code or Visual Studio 2022
diff --git a/aspire.config.json b/aspire.config.json
new file mode 100644
index 0000000..d84e4cd
--- /dev/null
+++ b/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj"
+ }
+}
\ No newline at end of file
diff --git a/global.json b/global.json
index a27a2b8..9a523dc 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "9.0.0",
+ "version": "10.0.0",
"rollForward": "latestMajor",
"allowPrerelease": false
}
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..993d13d
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/slides/Slides.md b/slides/Slides.md
index 9fbc9ae..0396205 100644
--- a/slides/Slides.md
+++ b/slides/Slides.md
@@ -192,7 +192,7 @@ footer: '@Chris_L_Ayers - https://chris-ayers.com'
- **Databases**: PostgreSQL, MySQL, MongoDB, SQL Server
- **Messaging**: Kafka, RabbitMQ, NATS
-- **AI & Observability**: Ollama, Semantic Kernel, OpenTelemetry
+- **AI & Observability**: Ollama, OpenAI, Foundry, OpenTelemetry
@@ -235,34 +235,34 @@ footer: '@Chris_L_Ayers - https://chris-ayers.com'
---
-# What's New in .NET Aspire 9.5
+# What's New in .NET Aspire 13.4
## ⚙️ CLI & Tooling
-- **aspire update** - Auto-update packages
-- **SSH Remote port forwarding** in VS Code
+- **TypeScript AppHost** now GA
+- **aspire deploy / publish** GA
+- **AI agent readiness** via skills
-## 🎨 Dashboard Enhancements
+## 🎨 Dashboard
-- **GenAI Visualizer** - Explore AI interactions
-- **Multi-resource console** logs view
+- **AI Agents dialog**
+- Structured search & trace filtering
-## 🤖 New Integrations
+## 🧩 App Model
-- **OpenAI hosting** integration
-- **GitHub Models** & **Azure AI Foundry** catalogs
-- **Dev Tunnels** hosting support
+- **Go** & **Bun** hosting integrations
+- **Blazor WebAssembly** hosting (preview)
## 🚀 Deployment
-- **Azure Container App Jobs**
-- **Built-in Azure deployment** via `aspire deploy`
+- **Azure Container App Jobs** stable
+- Richer **Kubernetes / AKS** APIs
@@ -324,7 +324,6 @@ aspire exec
```bash
azd up # Deploy everything
azd deploy # App only
-azd provision # Infrastructure only
azd init # Initialize
```
@@ -336,9 +335,7 @@ azd init # Initialize
- Auto-detects app structure
- Environment variable mapping
- Native Aspire support
-- Credential providers
- Key Vault integration
-- Secure access
@@ -368,8 +365,6 @@ var api = builder.AddProject("api")
- **Automatic prompting** for missing parameters
- **Rich form inputs** in dashboard
- **Secret masking** for sensitive data
-- **Validation support** with custom rules
-- **Save to user secrets** for persistence
**Input Types**: Text, Password, Choice, Boolean, Number
@@ -383,7 +378,7 @@ var api = builder.AddProject("api")
| **Component** | **Local** | **Cloud** |
|--------------|----------|----------|
| **AI Models** | Ollama, Foundry local, LM Studio | Azure OpenAI, AI Foundry |
-| **Models** | Llama 3, Phi-4, Qwen | GPT-4.1, GPT-5-mini, Claude 4 |
+| **Models** | Llama 3.2, Qwen2.5, Phi-4 | GPT-5, GPT-5-mini, Claude 4 |
| **Vector DB** | Qdrant, Chroma, pgvector | Azure AI Search, Cosmos DB |
**Features**: Zero-friction transitions • Emulator support • Hybrid development • Auto configuration • Secret management
@@ -430,7 +425,6 @@ public class ExampleService(IChatClient chatClient)
- **Privacy & Security**: All data stays on your machine
- **Offline Capability**: Work without internet connectivity
- **Fast Iteration**: No network latency for testing
-- **Model Experimentation**: Try different open-source models
@@ -461,11 +455,10 @@ public class ExampleService(IChatClient chatClient)
-- **Latest Models**: Access to GPT-4, Claude, and cutting-edge AI
+- **Latest Models**: Access to GPT-5, Claude 4, and cutting-edge AI
- **Global Availability**: 99.9% SLA with worldwide deployment
- **Managed Infrastructure**: No server maintenance required
- **Advanced Features**: Function calling, vision, audio processing
-- **Compliance Ready**: SOC 2, HIPAA, and enterprise certifications
@@ -491,7 +484,6 @@ public class ExampleService(IChatClient chatClient)
- **Inconsistent APIs**: Each framework had unique approaches
- **Integration Complexity**: Difficult to combine tools
- **Provider Lock-in**: Hard to switch AI providers
-- **Learning Curve**: Multiple frameworks to master
@@ -505,10 +497,9 @@ public class ExampleService(IChatClient chatClient)
## Unified Multi-Agent Platform
-- **Consolidation**: Replaces Semantic Kernel & AutoGen
-- **Built on Microsoft.Extensions.AI**: Leverages unified abstractions
-- **Multi-Agent Orchestration**: Coordinate multiple AI agents
-- **Event-Driven Architecture**: Flexible agent communication
+- **Now GA**: Unifies Semantic Kernel & AutoGen
+- **Built on Microsoft.Extensions.AI**: Unified abstractions
+- **Multi-Agent Orchestration**: Coordinate AI agents
@@ -516,11 +507,33 @@ public class ExampleService(IChatClient chatClient)
## Key Features
- **Agent Types**: Task-based, conversational, autonomous
-- **Memory Systems**: Short-term and long-term context
-- **Tool Integration**: Function calling and external tools
+- **Tool Integration**: MCP tools & function calling
- **Provider Agnostic**: Works with any AI backend
-**Learn more**: [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
+
+
+
+---
+
+# Model Context Protocol (MCP)
+
+
+
+
+## Standardized Tool Access
+
+- **Open protocol** for exposing tools to AI
+- **Dynamic discovery**: agents load tools at runtime
+- **Transport-agnostic**: stdio, HTTP, SSE
+
+
+
+
+## In this Demo
+
+- **MCP server** hosts Math, Weather, System & Text tools
+- **MAF agent** discovers tools via `McpClientTool`
+- **Local tool-calling** with Foundry Local (Qwen2.5)
@@ -547,19 +560,17 @@ public class ExampleService(IChatClient chatClient)
## Links
- [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview)
-- [What's new in .NET Aspire 9.5](https://learn.microsoft.com/en-us/dotnet/aspire/whats-new/dotnet-aspire-9.5)
+- [What's new in .NET Aspire 13.4](https://aspire.dev/whats-new/aspire-13-4/)
- [Microsoft.Extensions.AI](https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai)
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
-- [Aspirify](https://aspireify.net/)
- [Aspire Samples](https://github.com/dotnet/aspire-samples)
-- [eShopLite](https://github.com/Azure-Samples/eShopLite)
## Follow Chris Ayers
-
+
+
$(NETCoreSdkRuntimeIdentifier)
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs b/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs
index dacea68..4e1c6b5 100644
--- a/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs
+++ b/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs
@@ -16,31 +16,25 @@ public static RouteGroupBuilder MapChatEndpoints(this IEndpointRouteBuilder rout
var group = routes.MapGroup("/conversations");
group.MapGet("/", GetConversations)
- .WithName("GetConversations")
- .WithOpenApi();
+ .WithName("GetConversations");
group.MapGet("/{id}", GetConversation)
- .WithName("GetConversation")
- .WithOpenApi();
+ .WithName("GetConversation");
group.MapPost("/", CreateConversation)
- .WithName("CreateConversation")
- .WithOpenApi();
+ .WithName("CreateConversation");
group.MapPost("/{id}/messages", SendMessage)
.WithName("SendMessage")
- .WithOpenApi()
.RequireRateLimiting("chat");
group.MapDelete("/{id}", DeleteConversation)
- .WithName("DeleteConversation")
- .WithOpenApi();
+ .WithName("DeleteConversation");
// Legacy endpoint for backward compatibility
routes.MapGet("/chat", async (ChatService chatService, string message) =>
await chatService.ProcessMessage(message).ConfigureAwait(false))
.WithName("GetChat")
- .WithOpenApi()
.RequireRateLimiting("chat");
return group;
diff --git a/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs b/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs
index 6fb84d5..722930f 100644
--- a/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs
+++ b/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs
@@ -14,16 +14,13 @@ public static RouteGroupBuilder MapMcpEndpoints(this IEndpointRouteBuilder route
group.MapPost("/call/{toolName}", CallTool)
.WithName("CallMcpTool")
- .WithOpenApi()
.RequireRateLimiting("weather");
group.MapGet("/tools", ListTools)
- .WithName("ListMcpTools")
- .WithOpenApi();
+ .WithName("ListMcpTools");
group.MapGet("/health", HealthCheck)
- .WithName("McpHealthCheck")
- .WithOpenApi();
+ .WithName("McpHealthCheck");
return group;
}
diff --git a/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs b/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs
index 5b2c2dc..25365ad 100644
--- a/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs
+++ b/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs
@@ -14,7 +14,6 @@ public static RouteGroupBuilder MapWeatherEndpoints(this IEndpointRouteBuilder r
group.MapGet("/", GetWeatherForecast)
.WithName("GetWeatherForecast")
- .WithOpenApi()
.RequireRateLimiting("weather");
return group;
@@ -28,11 +27,8 @@ private static IAsyncEnumerable GetWeatherForecast(
var logger = loggerFactory.CreateLogger("WeatherForecast");
var weatherAgent = new Microsoft.Agents.AI.ChatClientAgent(
client,
- new Microsoft.Agents.AI.ChatClientAgentOptions
- {
- Name = "WeatherAssistant",
- Instructions = "You are a helpful assistant that provides a description of the weather in one word based on the temperature."
- });
+ instructions: "You are a helpful assistant that provides a description of the weather in one word based on the temperature.",
+ name: "WeatherAssistant");
return GetForecasts(weatherAgent, logger, settings);
}
diff --git a/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs b/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs
index 10386a0..e1c3059 100644
--- a/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs
+++ b/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs
@@ -34,15 +34,16 @@ private static IHostApplicationBuilder AddAIProvider(
break;
case AIConfiguration.AIProvider.AzureOpenAI:
- builder.AddAzureOpenAIClient("ai-service")
- .AddChatClient(aiSettings.DeploymentName);
- break;
-
- case AIConfiguration.AIProvider.GitHubModels:
- builder.AddOpenAIClient(aiSettings.DeploymentName)
+ builder.AddAzureOpenAIClient(aiSettings.DeploymentName)
.AddChatClient();
break;
+ // GitHub Models and Azure AI Foundry (local emulator + cloud) expose the
+ // Azure AI Inference / OpenAI-compatible chat completions protocol, so they
+ // use AddAzureChatCompletionsClient rather than the Azure OpenAI client
+ // (which targets the /openai/deployments/... URL scheme and 404s here).
+ // Matches the official Aspire FoundryEndToEnd / GitHubModelsEndToEnd playgrounds.
+ case AIConfiguration.AIProvider.GitHubModels:
case AIConfiguration.AIProvider.AzureAIFoundry:
builder.AddAzureChatCompletionsClient(aiSettings.DeploymentName)
.AddChatClient();
diff --git a/src/BuildWithAspire.ApiService/Services/ChatService.cs b/src/BuildWithAspire.ApiService/Services/ChatService.cs
index d1dc684..5b86eb9 100644
--- a/src/BuildWithAspire.ApiService/Services/ChatService.cs
+++ b/src/BuildWithAspire.ApiService/Services/ChatService.cs
@@ -1,3 +1,4 @@
+using System.Text.RegularExpressions;
using BuildWithAspire.Abstractions;
using BuildWithAspire.ApiService.Models;
using Microsoft.Extensions.AI;
@@ -5,7 +6,7 @@
namespace BuildWithAspire.ApiService.Services;
-public class ChatService
+public partial class ChatService
{
private AIAgent? _agent;
private readonly IChatClient _chatClient;
@@ -42,46 +43,32 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken =
_logger.LogInformation("Initializing AI Agent with dynamic MCP tools...");
- // Dynamically load tools from MCP server
+ // Dynamically load tools from the MCP server. McpClientTool instances derive from
+ // AIFunction (and therefore AITool), so they can be handed straight to the agent.
_tools = await _toolConverter.GetAllToolsAsync(cancellationToken).ConfigureAwait(false);
- _logger.LogInformation("Loaded {ToolCount} tools from MCP server", _tools.Count());
-
- // Create a chat client with function invocation enabled and tools configured via middleware
- var toolEnabledClient = _chatClient.AsBuilder()
- .Use((chatMessages, options, next, cancellationToken) =>
- {
- // Inject tools into ChatOptions for every request
- if (options.Tools?.Count is null or 0)
- {
- options.Tools = _tools?.Select(t => (AITool)t).ToList();
- _logger.LogInformation("Middleware: Injected {ToolCount} tools into ChatOptions", options.Tools?.Count ?? 0);
- }
-
- var toolCount = options.Tools?.Count ?? 0;
- _logger.LogInformation("Middleware: Sending request to model with {ToolCount} tools available", toolCount);
- var result = next(chatMessages, options, cancellationToken);
- _logger.LogInformation("Middleware: Received response from model");
- return result;
- })
- .UseFunctionInvocation()
- .Build();
-
- // Create an AI Agent using the Microsoft Agent Framework with dynamic tools
+ var tools = _tools.Cast().ToList();
+ _logger.LogInformation("Loaded {ToolCount} tools from MCP server", tools.Count);
+
+ // Create the agent using the Microsoft Agent Framework, supplying the MCP tools at
+ // construction time. ChatClientAgent automatically decorates the IChatClient with the
+ // function-invocation middleware (UseProvidedChatClientAsIs defaults to false), so we do
+ // not wire UseFunctionInvocation() or a custom tool-injection pipeline ourselves. This
+ // mirrors the official MAF agent pattern (see the aspire FoundryAgents playground).
_agent = new ChatClientAgent(
- toolEnabledClient,
- new ChatClientAgentOptions
- {
- Name = "ChatAssistant",
- Instructions = @"You are an AI demonstration application.
- You are a helpful chatbot with access to various tools dynamically discovered from the MCP server.
- Use the available tools when appropriate to provide accurate information.
- When a user asks for something that a tool can provide (like a random number, weather, calculations, etc.), USE THE TOOL instead of making up an answer.
- Respond to the user's input responsibly.
- All responses should be safe for work."
- });
+ _chatClient,
+ instructions: """
+ You are an AI demonstration application.
+ You are a helpful chatbot with access to various tools dynamically discovered from the MCP server.
+ Use the available tools when appropriate to provide accurate information.
+ When a user asks for something that a tool can provide (like a random number, weather, calculations, etc.), USE THE TOOL instead of making up an answer.
+ Respond to the user's input responsibly.
+ All responses should be safe for work.
+ """,
+ name: "ChatAssistant",
+ tools: tools);
_isInitialized = true;
- _logger.LogInformation("AI Agent initialized with {ToolCount} MCP tools available", _tools.Count());
+ _logger.LogInformation("AI Agent initialized with {ToolCount} MCP tools available", tools.Count);
}
public async Task ProcessMessage(string message)
@@ -104,7 +91,7 @@ public async Task ProcessMessage(string message)
var response = await _agent.RunAsync(message ?? string.Empty).ConfigureAwait(false);
var duration = DateTime.UtcNow - startTime;
- var combinedResponse = response.Text ?? string.Empty;
+ var combinedResponse = SanitizeResponse(response.Text);
_logger.LogInformation("AI response generated successfully. ResponseLength: {ResponseLength}, Duration: {Duration}ms",
combinedResponse.Length, duration.TotalMilliseconds);
@@ -168,7 +155,7 @@ public async Task ProcessMessagesWithHistory(List me
var response = await _agent.RunAsync(chatMessages, cancellationToken: cts.Token).ConfigureAwait(false);
var duration = DateTime.UtcNow - startTime;
- var combinedResponse = response.Text ?? string.Empty;
+ var combinedResponse = SanitizeResponse(response.Text);
_logger.LogInformation("AI conversation response generated successfully. InputMessages: {InputMessages}, ResponseLength: {ResponseLength}, Duration: {Duration}ms",
messageCount, combinedResponse.Length, duration.TotalMilliseconds);
@@ -190,4 +177,34 @@ public async Task ProcessMessagesWithHistory(List me
throw new InvalidOperationException($"Failed to process messages: {ex.Message}", ex);
}
}
+
+ ///
+ /// Removes tool-call template noise that some local models (e.g. the qwen2.5 / Hermes family
+ /// served through Foundry Local) echo into the text channel in addition to the structured
+ /// tool_calls the serving layer already parses. The tools still fire correctly; this only
+ /// cleans the visible answer. Model-agnostic and a no-op for models that emit clean text.
+ ///
+ public static string SanitizeResponse(string? text) {
+ if (string.IsNullOrEmpty(text))
+ {
+ return string.Empty;
+ }
+
+ var cleaned = ToolCallTemplateRegex().Replace(text, string.Empty);
+ cleaned = OrphanToolCallTagRegex().Replace(cleaned, string.Empty);
+ cleaned = ExcessBlankLinesRegex().Replace(cleaned, "\n\n");
+ return cleaned.Trim();
+ }
+
+ // Matches a complete ... block (Singleline so '.' spans newlines).
+ [GeneratedRegex(@".*?", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
+ private static partial Regex ToolCallTemplateRegex();
+
+ // Defensively removes any orphaned opening/closing tool_call tags left by a truncated block.
+ [GeneratedRegex(@"?tool_call>", RegexOptions.IgnoreCase)]
+ private static partial Regex OrphanToolCallTagRegex();
+
+ // Collapses the blank lines left behind after stripping a block.
+ [GeneratedRegex(@"\n{3,}")]
+ private static partial Regex ExcessBlankLinesRegex();
}
diff --git a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj
index c9b74f8..f753573 100644
--- a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj
+++ b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj
@@ -1,8 +1,8 @@
-
+
Exe
- net9.0
+ net10.0
enable
enable
true
@@ -10,17 +10,15 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
diff --git a/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs b/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs
index ad7f13a..0c28c9d 100644
--- a/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs
+++ b/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Configuration;
using Aspire.Hosting;
+using Aspire.Hosting.Foundry;
+using Aspire.Hosting.GitHub;
using BuildWithAspire.Abstractions;
using AIProvider = BuildWithAspire.Abstractions.AIConfiguration.AIProvider;
@@ -61,7 +63,7 @@ private static IResourceBuilder ConfigureAzureOpe
AIConfiguration.AISettings settings)
{
var config = builder.Configuration;
- var modelVersion = config["AI:ModelVersion"] ?? "2024-11-20";
+ var modelVersion = config["AI:ModelVersion"] ?? "2025-08-07";
var skuName = config["AI:SkuName"] ?? "GlobalStandard";
var skuCapacity = config.GetValue("AI:SkuCapacity") ?? 150;
@@ -101,8 +103,13 @@ private static IResourceBuilder ConfigureGitHubMo
IDistributedApplicationBuilder builder,
AIConfiguration.AISettings settings)
{
- return builder.AddGitHubModel(settings.DeploymentName, settings.Model)
- .WithHealthCheck();
+ // Default to the strongly-typed model catalog; fall back to the string overload
+ // only when a model is explicitly configured via AI:Model.
+ var github = settings.ModelExplicitlyConfigured
+ ? builder.AddGitHubModel(settings.DeploymentName, settings.Model)
+ : builder.AddGitHubModel(settings.DeploymentName, GitHubModel.OpenAI.OpenAIGpt5Mini);
+
+ return github.WithHealthCheck();
}
private static IResourceBuilder ConfigureAzureAIFoundry(
@@ -120,15 +127,25 @@ private static IResourceBuilder ConfigureAzureAIF
$"Azure AI Foundry: {(isLocal ? "Local" : "Cloud")}, " +
$"Model: {settings.Model}, Version: {version}");
- var foundry = builder.AddAzureAIFoundry(name);
+ var foundry = builder.AddFoundry(name);
if (isLocal)
{
foundry = foundry.RunAsFoundryLocal();
}
- return foundry
- .AddDeployment(settings.DeploymentName, settings.Model, version, format)
- .WithProperties(p => p.SkuCapacity = skuCapacity);
+ // Default to the strongly-typed model catalog (which encodes the correct model
+ // version/format per environment); fall back to the string overload only when a
+ // model is explicitly configured via AI:Model.
+ // Local default is qwen2.5-1.5b: unlike the Foundry-optimized phi-4-mini build (which
+ // does not emit tool calls), the qwen2.5 builds reliably invoke MCP tools. Swap to the
+ // larger FoundryModel.Local.Qwen257b (qwen2.5-7b) for higher tool-calling reliability.
+ var deployment = settings.ModelExplicitlyConfigured
+ ? foundry.AddDeployment(settings.DeploymentName, settings.Model, version, format)
+ : foundry.AddDeployment(
+ settings.DeploymentName,
+ isLocal ? FoundryModel.Local.Qwen2515b : FoundryModel.OpenAI.Gpt5Mini);
+
+ return deployment.WithProperties(p => p.SkuCapacity = skuCapacity);
}
private static IResourceBuilder ConnectToAIService(
diff --git a/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json b/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json
index d6cc9cf..c95d260 100644
--- a/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json
+++ b/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json
@@ -8,9 +8,9 @@
"AI": {
"Provider": "azureaifoundry",
"DeploymentName": "chat",
- "Model": "phi-3.5-mini",
- "ModelVersion": "1",
- "ModelFormat": "Microsoft",
+ "Model": "gpt-5-mini",
+ "ModelVersion": "2025-08-07",
+ "ModelFormat": "OpenAI",
"SkuCapacity": 20
}
}
diff --git a/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json b/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json
index dad2e78..ddee6d0 100644
--- a/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json
+++ b/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json
@@ -8,8 +8,8 @@
"AI": {
"Provider": "azureopenai",
"DeploymentName": "chat",
- "Model": "gpt-4o",
- "ModelVersion": "2024-11-20",
+ "Model": "gpt-5",
+ "ModelVersion": "2025-08-07",
"SkuName": "GlobalStandard",
"SkuCapacity": 10
}
diff --git a/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json b/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json
index f2357a1..1ddbd47 100644
--- a/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json
+++ b/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json
@@ -9,8 +9,8 @@
"AI": {
"Provider": "foundrylocal",
"DeploymentName": "chat",
- "Model": "phi-3.5-mini",
- "ModelVersion": "1",
+ "Model": "qwen2.5-1.5b",
+ "ModelVersion": "4",
"ModelFormat": "Microsoft",
"SkuCapacity": 20
}
diff --git a/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json b/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json
index 3771d7c..eca9185 100644
--- a/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json
+++ b/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json
@@ -9,6 +9,6 @@
"AI": {
"Provider": "githubmodels",
"DeploymentName": "chat",
- "Model": "openai/gpt-4.1-mini"
+ "Model": "openai/gpt-5-mini"
}
}
diff --git a/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj b/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj
index f5eaf7d..2f6a736 100644
--- a/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj
+++ b/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
Major
Exe
enable
@@ -24,13 +24,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/BuildWithAspire.MCPServer/Program.cs b/src/BuildWithAspire.MCPServer/Program.cs
index ab30ae7..64bf761 100644
--- a/src/BuildWithAspire.MCPServer/Program.cs
+++ b/src/BuildWithAspire.MCPServer/Program.cs
@@ -49,7 +49,8 @@
})
.WithTools()
.WithTools()
-.WithTools();
+.WithTools()
+.WithTools();
// Add logging
builder.Logging.AddConsole(options =>
@@ -73,8 +74,7 @@
sessionSupport = "Yes - via Mcp-Session-Id header"
});
})
-.WithName("HealthCheck")
-.WithOpenApi();
+.WithName("HealthCheck");
// Add diagnostic endpoint to show session and transport information
app.MapGet("/debug/info", () =>
@@ -99,31 +99,35 @@
note = "Session IDs are managed automatically by the SDK's StatefulSessionManager"
});
})
-.WithName("DebugInfo")
-.WithOpenApi();
+.WithName("DebugInfo");
-// Add diagnostic endpoint to list registered tools (for debugging)
-app.MapGet("/debug/tools", () =>
+// Add diagnostic endpoint to list registered tools (for debugging). Tool metadata is
+// enumerated directly from the DI-registered McpServerTool instances, so this endpoint
+// stays accurate automatically as tools are added or removed.
+app.MapGet("/debug/tools", (IEnumerable registeredTools) =>
{
- // This is a diagnostic endpoint to show the actual registered tool names
- var tools = new[]
- {
- "getWeatherForecast",
- "getCurrentWeather",
- "convertTemperature",
- "getCurrentDateTime",
- "getSystemInfo",
- "generateRandomNumber",
- "encodeToBase64",
- "decodeFromBase64",
- "calculate",
- "squareRoot",
- "power",
- "generateFibonacci",
- "isPrime"
- };
+ var tools = registeredTools
+ .Select(t => new
+ {
+ name = t.ProtocolTool.Name,
+ title = t.ProtocolTool.Title,
+ description = t.ProtocolTool.Description,
+ annotations = t.ProtocolTool.Annotations is { } a
+ ? new
+ {
+ readOnly = a.ReadOnlyHint,
+ idempotent = a.IdempotentHint,
+ destructive = a.DestructiveHint,
+ openWorld = a.OpenWorldHint
+ }
+ : null
+ })
+ .OrderBy(t => t.name, StringComparer.Ordinal)
+ .ToArray();
+
return Results.Ok(new {
message = "MCP tools registered with camelCase names (following official SDK conventions)",
+ count = tools.Length,
tools = tools,
usage = new
{
@@ -134,8 +138,7 @@
}
});
})
-.WithName("DebugTools")
-.WithOpenApi();
+.WithName("DebugTools");
// Add OpenAPI for development
if (app.Environment.IsDevelopment())
@@ -157,7 +160,7 @@
app.Logger.LogInformation(" - POST / : JSON-RPC requests (auto-creates session on first request)");
app.Logger.LogInformation(" - GET / : SSE notifications stream");
app.Logger.LogInformation(" - DELETE / : Session cleanup");
-app.Logger.LogInformation("Tools: Weather, System, Math");
+app.Logger.LogInformation("Tools: Weather, System, Math, Text");
app.Logger.LogInformation("Session Timeout: 2 hours idle");
// Run the MCP-compliant server
diff --git a/src/BuildWithAspire.MCPServer/Tools/MathTools.cs b/src/BuildWithAspire.MCPServer/Tools/MathTools.cs
index aed597b..3609f63 100644
--- a/src/BuildWithAspire.MCPServer/Tools/MathTools.cs
+++ b/src/BuildWithAspire.MCPServer/Tools/MathTools.cs
@@ -18,7 +18,7 @@ public MathTools(ILogger logger)
_logger = logger;
}
- [McpServerTool(Name = "calculate")]
+ [McpServerTool(Name = "calculate", Title = "Basic Calculator", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Performs basic arithmetic operations (add, subtract, multiply, divide).")]
public CalculationResult Calculate(
[Description("First number")] double a,
@@ -56,7 +56,7 @@ public CalculationResult Calculate(
}
}
- [McpServerTool(Name = "squareRoot")]
+ [McpServerTool(Name = "squareRoot", Title = "Square Root", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Calculates the square root of a number.")]
public CalculationResult SquareRoot(
[Description("Number to find square root of")] double number)
@@ -81,7 +81,7 @@ public CalculationResult SquareRoot(
);
}
- [McpServerTool(Name = "power")]
+ [McpServerTool(Name = "power", Title = "Exponentiation", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Raises a number to a specified power.")]
public CalculationResult Power(
[Description("Base number")] double baseNumber,
@@ -109,7 +109,7 @@ public CalculationResult Power(
}
}
- [McpServerTool(Name = "generateFibonacci")]
+ [McpServerTool(Name = "generateFibonacci", Title = "Fibonacci Sequence", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Generates the Fibonacci sequence up to n terms.")]
public FibonacciResult GenerateFibonacci(
[Description("Number of terms to generate (1-50)")] int terms)
@@ -153,7 +153,7 @@ public FibonacciResult GenerateFibonacci(
);
}
- [McpServerTool(Name = "isPrime")]
+ [McpServerTool(Name = "isPrime", Title = "Primality Test", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Checks if a number is prime.")]
public PrimeCheckResult IsPrime(
[Description("Number to check for primality")] long number)
diff --git a/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs b/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs
index a1f5845..b6090b2 100644
--- a/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs
+++ b/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs
@@ -20,7 +20,7 @@ public SystemTools(ILogger logger)
_logger = logger;
}
- [McpServerTool(Name = "getCurrentDateTime")]
+ [McpServerTool(Name = "getCurrentDateTime", Title = "Current Date and Time", ReadOnly = true, Idempotent = false, OpenWorld = false)]
[Description("Gets the current date and time information.")]
public DateTimeInfo GetCurrentDateTime()
{
@@ -36,7 +36,7 @@ public DateTimeInfo GetCurrentDateTime()
);
}
- [McpServerTool(Name = "getSystemInfo")]
+ [McpServerTool(Name = "getSystemInfo", Title = "System Information", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Gets basic system information including OS and .NET version.")]
public SystemInfo GetSystemInfo()
{
@@ -50,7 +50,7 @@ public SystemInfo GetSystemInfo()
);
}
- [McpServerTool(Name = "generateRandomNumber")]
+ [McpServerTool(Name = "generateRandomNumber", Title = "Random Number Generator", ReadOnly = true, Idempotent = false, OpenWorld = false)]
[Description("Generates a random number within the specified range.")]
public RandomNumber GenerateRandomNumber(
[Description("Minimum value (inclusive)")] int min = 1,
@@ -66,7 +66,7 @@ public RandomNumber GenerateRandomNumber(
return new RandomNumber(value, min, max - 1);
}
- [McpServerTool(Name = "encodeToBase64")]
+ [McpServerTool(Name = "encodeToBase64", Title = "Base64 Encode", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Encodes text to Base64 format.")]
public EncodingResult EncodeToBase64(
[Description("Text to encode")] string text)
@@ -89,7 +89,7 @@ public EncodingResult EncodeToBase64(
}
}
- [McpServerTool(Name = "decodeFromBase64")]
+ [McpServerTool(Name = "decodeFromBase64", Title = "Base64 Decode", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Decodes Base64 text to plain text.")]
public EncodingResult DecodeFromBase64(
[Description("Base64 text to decode")] string base64Text)
diff --git a/src/BuildWithAspire.MCPServer/Tools/TextTools.cs b/src/BuildWithAspire.MCPServer/Tools/TextTools.cs
new file mode 100644
index 0000000..9dfea2d
--- /dev/null
+++ b/src/BuildWithAspire.MCPServer/Tools/TextTools.cs
@@ -0,0 +1,128 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+
+namespace BuildWithAspire.MCPServer.Tools;
+
+///
+/// Text manipulation and analysis tools for MCP clients.
+/// All operations are deterministic, read-only, and side-effect free, which makes
+/// them ideal for demonstrating tool-calling with small local models.
+///
+[McpServerToolType]
+public sealed class TextTools
+{
+ private static readonly char[] SentenceTerminators = ['.', '!', '?'];
+
+ private readonly ILogger _logger;
+
+ public TextTools(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ [McpServerTool(Name = "countText", Title = "Text Statistics", ReadOnly = true, Idempotent = true, OpenWorld = false)]
+ [Description("Counts the number of characters, words, lines, and sentences in the supplied text.")]
+ public TextStatistics CountText(
+ [Description("The text to analyze")] string text)
+ {
+ _logger.LogInformation("MCP Tool 'countText' called with text length={Length}", text?.Length ?? 0);
+
+ if (string.IsNullOrEmpty(text))
+ {
+ return new TextStatistics(0, 0, 0, 0, 0);
+ }
+
+ var characters = text.Length;
+ var charactersNoWhitespace = text.Count(c => !char.IsWhiteSpace(c));
+ var words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
+ var lines = text.Split('\n').Length;
+ var sentences = text.Split(SentenceTerminators, StringSplitOptions.RemoveEmptyEntries)
+ .Count(s => !string.IsNullOrWhiteSpace(s));
+
+ return new TextStatistics(characters, charactersNoWhitespace, words, lines, sentences);
+ }
+
+ [McpServerTool(Name = "transformCase", Title = "Transform Text Case", ReadOnly = true, Idempotent = true, OpenWorld = false)]
+ [Description("Transforms text casing. Supported modes: 'upper', 'lower', 'title', 'reverse'.")]
+ public TextTransformResult TransformCase(
+ [Description("The text to transform")] string text,
+ [Description("Transform mode: 'upper', 'lower', 'title', or 'reverse'")] string mode = "upper")
+ {
+ _logger.LogInformation("MCP Tool 'transformCase' called with mode={Mode}", mode);
+
+ text ??= string.Empty;
+ var normalizedMode = mode?.Trim().ToLowerInvariant() ?? "upper";
+
+ switch (normalizedMode)
+ {
+ case "upper":
+ return new TextTransformResult(text.ToUpperInvariant(), normalizedMode, true, "Converted to upper case");
+ case "lower":
+ return new TextTransformResult(text.ToLowerInvariant(), normalizedMode, true, "Converted to lower case");
+ case "title":
+ var title = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(text.ToLowerInvariant());
+ return new TextTransformResult(title, normalizedMode, true, "Converted to title case");
+ case "reverse":
+ var reversed = new string(text.Reverse().ToArray());
+ return new TextTransformResult(reversed, normalizedMode, true, "Reversed the text");
+ default:
+ return new TextTransformResult(text, normalizedMode, false, $"Unknown mode: {mode}. Use 'upper', 'lower', 'title', or 'reverse'.");
+ }
+ }
+
+ [McpServerTool(Name = "slugify", Title = "URL Slug Generator", ReadOnly = true, Idempotent = true, OpenWorld = false)]
+ [Description("Converts text into a URL-friendly slug (lowercase, hyphen-separated, alphanumeric).")]
+ public SlugResult Slugify(
+ [Description("The text to convert into a slug")] string text)
+ {
+ _logger.LogInformation("MCP Tool 'slugify' called with text length={Length}", text?.Length ?? 0);
+
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return new SlugResult(string.Empty, "Empty input provided");
+ }
+
+ var builder = new StringBuilder(text.Length);
+ var previousWasHyphen = false;
+
+ foreach (var ch in text.Trim().ToLowerInvariant())
+ {
+ if (char.IsLetterOrDigit(ch))
+ {
+ builder.Append(ch);
+ previousWasHyphen = false;
+ }
+ else if (!previousWasHyphen)
+ {
+ builder.Append('-');
+ previousWasHyphen = true;
+ }
+ }
+
+ var slug = builder.ToString().Trim('-');
+ return new SlugResult(slug, $"Generated slug from {text.Length} characters");
+ }
+}
+
+public record TextStatistics(
+ int Characters,
+ int CharactersNoWhitespace,
+ int Words,
+ int Lines,
+ int Sentences
+);
+
+public record TextTransformResult(
+ string Result,
+ string Mode,
+ bool Success,
+ string Message
+);
+
+public record SlugResult(
+ string Slug,
+ string Message
+);
diff --git a/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs b/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs
index 18678bc..f154bb1 100644
--- a/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs
+++ b/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs
@@ -21,7 +21,7 @@ public WeatherTools(ILogger logger, IChatClient? chatClient = null
_chatClient = chatClient;
}
- [McpServerTool(Name = "getWeatherForecast")]
+ [McpServerTool(Name = "getWeatherForecast", Title = "Weather Forecast", ReadOnly = true, Idempotent = false, OpenWorld = false)]
[Description("Gets a weather forecast for the next 5 days with AI-generated weather descriptions.")]
public async Task GetWeatherForecast(
[Description("Maximum number of forecast days to return (1-10)")] int maxDays = 5)
@@ -54,7 +54,7 @@ public async Task GetWeatherForecast(
return forecasts.ToArray();
}
- [McpServerTool(Name = "getCurrentWeather")]
+ [McpServerTool(Name = "getCurrentWeather", Title = "Current Weather", ReadOnly = true, Idempotent = false, OpenWorld = false)]
[Description("Gets current weather information for today with AI-generated description.")]
public async Task GetCurrentWeather()
{
@@ -70,7 +70,7 @@ public async Task GetCurrentWeather()
);
}
- [McpServerTool(Name = "convertTemperature")]
+ [McpServerTool(Name = "convertTemperature", Title = "Temperature Converter", ReadOnly = true, Idempotent = true, OpenWorld = false)]
[Description("Converts temperature between Celsius and Fahrenheit.")]
public static TemperatureConversion ConvertTemperature(
[Description("Temperature value to convert")] double temperature,
diff --git a/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj b/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj
index 96158ee..bb37d29 100644
--- a/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj
+++ b/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
true
@@ -10,13 +10,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj b/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj
index 4831bd3..c6588db 100644
--- a/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj
+++ b/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj
@@ -1,16 +1,15 @@
- net9.0
+ net10.0
enable
enable
-
-
+
+
-
diff --git a/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj b/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj
index 17f72ff..61f3f9d 100644
--- a/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj
+++ b/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj
@@ -1,31 +1,33 @@
- net9.0
+ net10.0
enable
enable
false
true
$(NoWarn);xUnit1013
+
+ $(NETCoreSdkRuntimeIdentifier)
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
-
-
+
+
+
+
+
diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs
index 03b47af..a5a6ab8 100644
--- a/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs
+++ b/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs
@@ -40,10 +40,10 @@ public void GetProvider_WithMissingConfiguration_ShouldReturnDefaultOllama()
}
[Theory]
- [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-4o")]
+ [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-5")]
[InlineData(AIConfiguration.AIProvider.Ollama, "llama3.2")]
- [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-4o-mini")]
- [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "phi-3.5-mini")]
+ [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-5-mini")]
+ [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "gpt-5-mini")]
public void GetModel_WithProviders_ShouldReturnCorrectDefaults(AIConfiguration.AIProvider provider, string expectedModel)
{
// Arrange
@@ -56,6 +56,26 @@ public void GetModel_WithProviders_ShouldReturnCorrectDefaults(AIConfiguration.A
Assert.Equal(expectedModel, model);
}
+ [Theory]
+ [InlineData("foundrylocal", "qwen2.5-1.5b")]
+ [InlineData("azureaifoundry", "gpt-5-mini")]
+ public void GetModel_AzureAIFoundry_ShouldReturnEnvironmentSpecificDefault(string providerString, string expectedModel)
+ {
+ // Arrange
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["AI:Provider"] = providerString
+ })
+ .Build();
+
+ // Act
+ var model = AIConfiguration.GetModel(configuration, AIConfiguration.AIProvider.AzureAIFoundry);
+
+ // Assert
+ Assert.Equal(expectedModel, model);
+ }
+
[Fact]
public void AISettings_WithAllProviders_ShouldConstructCorrectly()
{
diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs
index ba86b18..384d349 100644
--- a/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs
+++ b/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs
@@ -64,9 +64,9 @@ public void GetModel_WithCustomModel_ReturnsCustomModel()
[Theory]
[InlineData(AIConfiguration.AIProvider.Ollama, "llama3.2")]
- [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-4o")]
- [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-4o-mini")]
- [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "phi-3.5-mini")]
+ [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-5")]
+ [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-5-mini")]
+ [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "gpt-5-mini")]
public void GetModel_WithDefaultConfiguration_ReturnsProviderDefaults(AIConfiguration.AIProvider provider, string expectedModel)
{
// Arrange
diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs
new file mode 100644
index 0000000..e67cadb
--- /dev/null
+++ b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs
@@ -0,0 +1,60 @@
+using BuildWithAspire.ApiService.Services;
+
+namespace BuildWithAspire.ApiService.UnitTests.Services;
+
+public class ChatServiceSanitizeTests
+{
+ [Fact]
+ public void SanitizeResponse_StripsToolCallTemplate_KeepsAnswer()
+ {
+ // qwen2.5 / Hermes-style local models echo the tool-call template into the text channel
+ // in addition to the structured tool_calls the serving layer already parsed.
+ var raw = "\n{\"name\": \"squareRoot\", \"arguments\": {\"number\": 1764}}\n\nThe square root of 1764 is 42.";
+
+ var cleaned = ChatService.SanitizeResponse(raw);
+
+ Assert.Equal("The square root of 1764 is 42.", cleaned);
+ Assert.DoesNotContain("tool_call", cleaned, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void SanitizeResponse_StripsMultipleToolCallBlocks()
+ {
+ var raw = "{\"name\":\"a\"}Line one.\n{\"name\":\"b\"}\nLine two.";
+
+ var cleaned = ChatService.SanitizeResponse(raw);
+
+ Assert.DoesNotContain("tool_call", cleaned, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Line one.", cleaned);
+ Assert.Contains("Line two.", cleaned);
+ }
+
+ [Fact]
+ public void SanitizeResponse_RemovesOrphanedToolCallTag()
+ {
+ var raw = "Partial answer.\n\n{\"name\": \"weather\"}";
+
+ var cleaned = ChatService.SanitizeResponse(raw);
+
+ Assert.DoesNotContain("", cleaned, StringComparison.OrdinalIgnoreCase);
+ Assert.StartsWith("Partial answer.", cleaned);
+ }
+
+ [Fact]
+ public void SanitizeResponse_LeavesCleanTextUnchanged()
+ {
+ var raw = "The weather in Seattle is 55F and cloudy.";
+
+ var cleaned = ChatService.SanitizeResponse(raw);
+
+ Assert.Equal(raw, cleaned);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void SanitizeResponse_HandlesNullOrEmpty(string? input)
+ {
+ Assert.Equal(string.Empty, ChatService.SanitizeResponse(input));
+ }
+}
diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs
index f4a3626..af65f05 100644
--- a/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs
+++ b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs
@@ -107,7 +107,7 @@ public void GetModel_WithAzureOpenAIProvider_ShouldReturnDefaultModel()
var model = AIConfiguration.GetModel(configuration, AIConfiguration.AIProvider.AzureOpenAI);
// Assert
- Assert.Equal("gpt-4o", model);
+ Assert.Equal("gpt-5", model);
}
[Fact]
diff --git a/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj b/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj
index ef596f2..d871885 100644
--- a/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj
+++ b/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
@@ -10,12 +10,12 @@
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs b/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs
index c38866d..4a5c879 100644
--- a/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs
+++ b/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs
@@ -89,9 +89,9 @@ public void GetAIModel_WithDefaultProvider_ReturnsExpectedDefaults()
// Act & Assert - Test each provider's default model
Assert.Equal("llama3.2", GetAIModelFromConfiguration(configuration, AIProvider.Ollama));
- Assert.Equal("gpt-4o", GetAIModelFromConfiguration(configuration, AIProvider.AzureOpenAI));
- Assert.Equal("openai/gpt-4o-mini", GetAIModelFromConfiguration(configuration, AIProvider.GitHubModels));
- Assert.Equal("phi-3.5-mini", GetAIModelFromConfiguration(configuration, AIProvider.AzureAIFoundry));
+ Assert.Equal("gpt-5", GetAIModelFromConfiguration(configuration, AIProvider.AzureOpenAI));
+ Assert.Equal("openai/gpt-5-mini", GetAIModelFromConfiguration(configuration, AIProvider.GitHubModels));
+ Assert.Equal("gpt-5-mini", GetAIModelFromConfiguration(configuration, AIProvider.AzureAIFoundry));
}
[Fact]
@@ -119,22 +119,9 @@ private static AIProvider GetAIProviderFromConfiguration(IConfiguration configur
private static string GetAIModelFromConfiguration(IConfiguration configuration, AIProvider provider)
{
- // Simulate what AIConfiguration.GetSettings does but forcing a provider for test determinism
- var model = configuration["AI:Model"]; // If user overrides model we honor it
- if (!string.IsNullOrWhiteSpace(model))
- {
- return provider == AIProvider.GitHubModels && !model.Contains('/')
- ? $"openai/{model}" // normalization mirrors central logic
- : model;
- }
-
- return provider switch
- {
- AIProvider.Ollama => "llama3.2",
- AIProvider.AzureOpenAI => "gpt-4o",
- AIProvider.GitHubModels => "openai/gpt-4o-mini",
- AIProvider.AzureAIFoundry => "phi-3.5-mini",
- _ => throw new InvalidOperationException($"No default model available for provider: {provider}")
- };
+ // Delegate to the central resolution logic so this test stays in sync with
+ // AIConfiguration.GetModel (override handling, GitHub normalization, and the
+ // Foundry Local vs cloud default all live there).
+ return AIConfiguration.GetModel(configuration, provider);
}
}
diff --git a/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj b/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj
index b1bafb2..f7abfe3 100644
--- a/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj
+++ b/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
false
@@ -10,21 +10,20 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
-
+
+
+