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 -![w:400px](./img/chris_ayers.svg) +![w:300px](./img/chris_ayers.svg) + $(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(@"", 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 - - - - + + +