diff --git a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj index 20ffba5d..1272ea00 100644 --- a/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj +++ b/EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj @@ -9,6 +9,7 @@ + diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index ccc3c5b5..96444deb 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Options; using Microsoft.SemanticKernel; using Npgsql; @@ -15,6 +16,7 @@ namespace EssentialCSharp.Chat.Common.Extensions; public static class ServiceCollectionExtensions { private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"]; + private const string LocalChatHttpClientName = "LocalAIChat"; /// /// Adds Azure OpenAI and related AI services to the service collection using Managed Identity @@ -85,6 +87,83 @@ public static IServiceCollection AddAzureOpenAIServices( return services; } + /// + /// Registers chat services using configuration-driven backend selection. + /// This method never throws for missing or partial AI configuration; it falls back to + /// so the app can continue running. + /// + public static IServiceCollection AddConfiguredChatServices(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("AIOptions")); + + var aiOptions = configuration.GetSection("AIOptions").Get() ?? new AIOptions(); + string? postgresConnectionString = configuration.GetConnectionString("PostgresVectorStore"); + + bool hasAzureEndpoint = !string.IsNullOrWhiteSpace(aiOptions.Endpoint); + bool hasAzureChatDeployment = !string.IsNullOrWhiteSpace(aiOptions.ChatDeploymentName); + bool hasAzureVectorDeployment = !string.IsNullOrWhiteSpace(aiOptions.VectorGenerationDeploymentName); + bool hasAzureConfig = hasAzureEndpoint && hasAzureChatDeployment && hasAzureVectorDeployment + && IsValidNpgsqlConnectionString(postgresConnectionString); + + string localEndpoint = ResolveLocalEndpoint(aiOptions, configuration); + bool hasLocalConfig = aiOptions.UseLocalAI + && !string.IsNullOrWhiteSpace(localEndpoint) + && !string.IsNullOrWhiteSpace(aiOptions.LocalChatModel); + + if (hasAzureConfig) + { + // Pre-validate endpoint URI to avoid exceptions in AddAzureOpenAIServices for + // non-empty but invalid endpoint values. + if (!Uri.TryCreate(aiOptions.Endpoint, UriKind.Absolute, out var azureUri) + || azureUri.Scheme != Uri.UriSchemeHttps) + { + Console.Error.WriteLine("[AI] Azure endpoint must be a valid https URI. Falling back to local/unavailable."); + } + else + { + services.AddAzureOpenAIServices(aiOptions, postgresConnectionString!); + // Bind EmbeddingRetry from config so operator appsettings/env overrides are honored. + // The AIOptions overload of AddAzureOpenAIServices only registers validation, not config binding. + services.AddOptions() + .Bind(configuration.GetSection(EmbeddingRetryOptions.SectionPath)); + services.AddSingleton(provider => provider.GetRequiredService()); + Console.WriteLine("[AI] Selected backend: Azure/Foundry."); + return services; + } + } + + if (hasLocalConfig) + { + if (!Uri.TryCreate(localEndpoint, UriKind.Absolute, out var localEndpointUri) + || (localEndpointUri.Scheme != Uri.UriSchemeHttp && localEndpointUri.Scheme != Uri.UriSchemeHttps)) + { + services.AddSingleton(); + Console.Error.WriteLine("[AI] Local backend selected but LocalEndpoint is invalid. Falling back to unavailable backend."); + return services; + } + +#pragma warning disable EXTEXP0001 + services.AddHttpClient(LocalChatHttpClientName, client => + { + client.BaseAddress = localEndpointUri; + client.Timeout = TimeSpan.FromSeconds(120); + }) + // Disable the global standard resilience handler (set by ConfigureHttpClientDefaults + // in Program.cs). Its default attempt timeout (30s) and total timeout (90s) would + // cut off long local-LLM completions. We set HttpClient.Timeout directly instead. + // Retries are also wrong for LLM calls (non-idempotent, partial responses). + .RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + services.AddSingleton(); + Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible)."); + return services; + } + + services.AddSingleton(); + Console.WriteLine("[AI] Selected backend: Unavailable (missing or invalid AI configuration)."); + return services; + } + /// /// Adds Azure OpenAI and related AI services to the service collection using configuration /// @@ -198,4 +277,34 @@ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity( return services; } + private static string ResolveLocalEndpoint(AIOptions options, IConfiguration configuration) + { + if (!string.IsNullOrWhiteSpace(options.LocalEndpoint)) + { + return options.LocalEndpoint!; + } + + return configuration.GetConnectionString("ollama-chat") ?? string.Empty; + } + + /// + /// Returns true if can be parsed by + /// and resolves to a non-empty Host. + /// Rejects null, empty, and placeholder strings like "your-postgres-connection-string-here". + /// + private static bool IsValidNpgsqlConnectionString(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + return false; + try + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + return !string.IsNullOrWhiteSpace(builder.Host); + } + catch (ArgumentException) + { + return false; + } + } + } diff --git a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs index 87a01a87..d0574fba 100644 --- a/EssentialCSharp.Chat.Shared/Models/AIOptions.cs +++ b/EssentialCSharp.Chat.Shared/Models/AIOptions.cs @@ -2,6 +2,21 @@ public class AIOptions { + /// + /// Enables local AI backend usage when Azure endpoint is not configured. + /// + public bool UseLocalAI { get; set; } + + /// + /// Local OpenAI-compatible endpoint (for example, Ollama). + /// + public string? LocalEndpoint { get; set; } + + /// + /// Local chat model identifier (for example, qwen2.5-coder:7b). + /// + public string? LocalChatModel { get; set; } + /// /// The Azure OpenAI deployment name for text embedding generation. /// diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index a66bbc65..ac97cd0e 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -12,7 +12,7 @@ namespace EssentialCSharp.Chat.Common.Services; /// /// Service for handling AI chat completions using the OpenAI Responses API /// -public partial class AIChatService +public partial class AIChatService : IChatCompletionService { private readonly AIOptions _Options; private readonly AzureOpenAIClient _AzureClient; @@ -22,6 +22,7 @@ public partial class AIChatService private readonly AISearchService _SearchService; private readonly ILogger _Logger; private readonly FrozenSet _AllowedMcpTools; + public bool IsAvailable => true; public AIChatService(IOptions options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger logger) { diff --git a/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs new file mode 100644 index 00000000..61346060 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/ChatBackendUnavailableException.cs @@ -0,0 +1,4 @@ +namespace EssentialCSharp.Chat.Common.Services; + +public class ChatBackendUnavailableException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs b/EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs new file mode 100644 index 00000000..67daf98c --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public interface IChatCompletionService +{ + bool IsAvailable { get; } + + Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default); + + IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default); +} diff --git a/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs new file mode 100644 index 00000000..c77ee1e6 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/LocalChatService.cs @@ -0,0 +1,220 @@ +using System.Linq; +using System.IO; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public partial class LocalChatService : IChatCompletionService, IDisposable +{ + private const int MaxConversationMessages = 20; + private const int MaxConversationEntries = 500; + private static readonly TimeSpan _HistoryTtl = TimeSpan.FromMinutes(30); + + private readonly AIOptions _Options; + private readonly IHttpClientFactory _HttpClientFactory; + private readonly ILogger _Logger; + private readonly MemoryCache _ConversationHistory = new(new MemoryCacheOptions { SizeLimit = MaxConversationEntries }); + + public bool IsAvailable => true; + + public LocalChatService(IOptions options, IHttpClientFactory httpClientFactory, ILogger logger) + { + _Options = options.Value; + _HttpClientFactory = httpClientFactory; + _Logger = logger; + } + + public void Dispose() + { + _ConversationHistory.Dispose(); + GC.SuppressFinalize(this); + } + + public async Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default) + { + var (client, history, jsonPayload) = PrepareRequest(prompt, systemPrompt, previousResponseId); + + HttpResponseMessage response; + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") + { + Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "local-dev-key"); + + try + { + response = await client.SendAsync(request, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new ChatBackendUnavailableException("Local AI backend is unavailable.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new ChatBackendUnavailableException("Local AI backend timed out.", ex); + } + + using (response) + { + string body; + try + { + body = await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (HttpRequestException ex) + { + throw new ChatBackendUnavailableException("Local AI backend is unavailable while reading response.", ex); + } + catch (IOException ex) + { + throw new ChatBackendUnavailableException("Local AI backend connection closed while reading response.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new ChatBackendUnavailableException("Local AI backend timed out while reading response.", ex); + } + + if (!response.IsSuccessStatusCode) + { + LogLocalRequestFailed(_Logger, (int)response.StatusCode, body); + throw new ChatBackendUnavailableException("Local AI backend returned a non-success status."); + } + + try + { + var (text, responseId) = ParseResponse(body); + history.Add(new LocalChatMessage("user", prompt)); + history.Add(new LocalChatMessage("assistant", text)); + var entryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(_HistoryTtl) + .SetSize(1); + _ConversationHistory.Set(responseId, history.TakeLast(MaxConversationMessages).ToList(), entryOptions); + return (text, responseId); + } + catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException) + { + throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", ex); + } + } + } + + public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var (response, responseId) = await GetChatCompletion( + prompt, + systemPrompt, + previousResponseId, + mcpClient, + tools, + reasoningEffortLevel, + enableContextualSearch, + endUserId, + cancellationToken); + + if (!string.IsNullOrEmpty(response)) + { + yield return (response, responseId: null); + } + + yield return (string.Empty, responseId); + } + + private (HttpClient Client, List History, string JsonPayload) PrepareRequest( + string prompt, + string? systemPrompt, + string? previousResponseId = null) + { + var client = _HttpClientFactory.CreateClient("LocalAIChat"); + var effectiveSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? _Options.SystemPrompt : systemPrompt; + var history = ResolveHistory(previousResponseId); + + var messages = new List { new { role = "system", content = effectiveSystemPrompt } }; + messages.AddRange(history.Select(m => new { role = m.Role, content = m.Content })); + messages.Add(new { role = "user", content = prompt }); + + var payload = new + { + model = _Options.LocalChatModel, + messages, + stream = false + }; + + return (client, history, JsonSerializer.Serialize(payload)); + } + + private List ResolveHistory(string? previousResponseId) + { + if (string.IsNullOrWhiteSpace(previousResponseId)) + return []; + + return _ConversationHistory.TryGetValue(previousResponseId, out List? history) && history is not null + ? [.. history] + : []; + } + + private static (string Text, string ResponseId) ParseResponse(string body) + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + string responseId = root.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String + ? idProp.GetString() ?? Guid.NewGuid().ToString("N") + : Guid.NewGuid().ToString("N"); + + if (root.TryGetProperty("choices", out var choices) + && choices.ValueKind == JsonValueKind.Array + && choices.GetArrayLength() > 0) + { + var choice = choices[0]; + if (choice.TryGetProperty("message", out var message) + && message.TryGetProperty("content", out var content) + && content.ValueKind == JsonValueKind.String) + { + return (content.GetString() ?? string.Empty, responseId); + } + } + + if (root.TryGetProperty("message", out var ollamaMessage) + && ollamaMessage.TryGetProperty("content", out var ollamaContent) + && ollamaContent.ValueKind == JsonValueKind.String) + { + return (ollamaContent.GetString() ?? string.Empty, responseId); + } + + throw new InvalidOperationException("Local AI response did not contain any content."); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Local chat request failed with status {StatusCode}. Body: {Body}")] + private static partial void LogLocalRequestFailed(ILogger logger, int statusCode, string body); + + private sealed record LocalChatMessage(string Role, string Content); +} diff --git a/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs b/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs new file mode 100644 index 00000000..ad536960 --- /dev/null +++ b/EssentialCSharp.Chat.Shared/Services/UnavailableChatService.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Client; +using OpenAI.Responses; + +namespace EssentialCSharp.Chat.Common.Services; + +public class UnavailableChatService : IChatCompletionService +{ + public bool IsAvailable => false; + + public Task<(string response, string responseId)> GetChatCompletion( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default) + { + throw new ChatBackendUnavailableException("Chat service is unavailable in this environment."); + } + + public IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream( + string prompt, + string? systemPrompt = null, + string? previousResponseId = null, + McpClient? mcpClient = null, +#pragma warning disable OPENAI001 + IEnumerable? tools = null, + ResponseReasoningEffortLevel? reasoningEffortLevel = null, +#pragma warning restore OPENAI001 + bool enableContextualSearch = false, + string? endUserId = null, + CancellationToken cancellationToken = default) + { + throw new ChatBackendUnavailableException("Chat service is unavailable in this environment."); + } +} diff --git a/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs b/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs new file mode 100644 index 00000000..9b2790a6 --- /dev/null +++ b/EssentialCSharp.Chat.Tests/LocalChatServiceTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using EssentialCSharp.Chat; +using EssentialCSharp.Chat.Common.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace EssentialCSharp.Chat.Tests; + +public class LocalChatServiceTests +{ + [Test] + public async Task GetChatCompletion_BuildsExpectedRequest() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(CreateJsonResponse(""" + {"id":"resp-1","choices":[{"message":{"content":"hello back"}}]} + """)); + + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; + + var (response, responseId) = await service.GetChatCompletion("hello"); + + await Assert.That(response).IsEqualTo("hello back"); + await Assert.That(responseId).IsEqualTo("resp-1"); + await Assert.That(requests.Count).IsEqualTo(1); + + var request = requests[0]; + await Assert.That(request.RequestUri!.AbsolutePath).IsEqualTo("/v1/chat/completions"); + await Assert.That(request.Headers.Authorization?.Scheme).IsEqualTo("Bearer"); + await Assert.That(request.Headers.Authorization?.Parameter).IsEqualTo("local-dev-key"); + + string requestBody = await request.Content!.ReadAsStringAsync(); + using var payload = JsonDocument.Parse(requestBody); + await Assert.That(payload.RootElement.GetProperty("model").GetString()).IsEqualTo("qwen2.5-coder:7b"); + await Assert.That(payload.RootElement.GetProperty("stream").GetBoolean()).IsFalse(); + + var messages = payload.RootElement.GetProperty("messages"); + await Assert.That(messages.GetArrayLength()).IsEqualTo(2); + await Assert.That(messages[0].GetProperty("role").GetString()).IsEqualTo("system"); + await Assert.That(messages[0].GetProperty("content").GetString()).IsEqualTo("system prompt"); + await Assert.That(messages[1].GetProperty("role").GetString()).IsEqualTo("user"); + await Assert.That(messages[1].GetProperty("content").GetString()).IsEqualTo("hello"); + } + + [Test] + public async Task GetChatCompletion_WhenBackendReturnsNonSuccess_ThrowsChatBackendUnavailableException() + { + var requests = new List(); + var responses = new Queue(); + using var response = new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("upstream error", Encoding.UTF8, "text/plain") + }; + responses.Enqueue(response); + + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; + + await Assert.ThrowsAsync(() => service.GetChatCompletion("hello")); + } + + [Test] + public async Task GetChatCompletion_WhenPayloadIsInvalid_ThrowsChatBackendUnavailableException() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(CreateJsonResponse("""{"id":"resp-1","choices":[]}""")); + + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; + + await Assert.ThrowsAsync(() => service.GetChatCompletion("hello")); + } + + [Test] + public async Task GetChatCompletion_ReusesConversationHistory_WhenPreviousResponseIdProvided() + { + var requests = new List(); + var responses = new Queue(); + responses.Enqueue(CreateJsonResponse(""" + {"id":"resp-1","choices":[{"message":{"content":"assistant one"}}]} + """)); + responses.Enqueue(CreateJsonResponse(""" + {"id":"resp-2","choices":[{"message":{"content":"assistant two"}}]} + """)); + + var (service, client) = CreateService(new RecordingHttpMessageHandler(requests, responses)); + using var clientScope = client; + using var serviceScope = service; + + var first = await service.GetChatCompletion("first"); + _ = await service.GetChatCompletion("second", previousResponseId: first.responseId); + + await Assert.That(requests.Count).IsEqualTo(2); + + string firstBody = await requests[0].Content!.ReadAsStringAsync(); + using var firstPayload = JsonDocument.Parse(firstBody); + var firstMessages = firstPayload.RootElement.GetProperty("messages"); + await Assert.That(firstMessages.GetArrayLength()).IsEqualTo(2); + + string secondBody = await requests[1].Content!.ReadAsStringAsync(); + using var secondPayload = JsonDocument.Parse(secondBody); + var secondMessages = secondPayload.RootElement.GetProperty("messages"); + await Assert.That(secondMessages.GetArrayLength()).IsEqualTo(4); + await Assert.That(secondMessages[1].GetProperty("role").GetString()).IsEqualTo("user"); + await Assert.That(secondMessages[1].GetProperty("content").GetString()).IsEqualTo("first"); + await Assert.That(secondMessages[2].GetProperty("role").GetString()).IsEqualTo("assistant"); + await Assert.That(secondMessages[2].GetProperty("content").GetString()).IsEqualTo("assistant one"); + await Assert.That(secondMessages[3].GetProperty("role").GetString()).IsEqualTo("user"); + await Assert.That(secondMessages[3].GetProperty("content").GetString()).IsEqualTo("second"); + } + + private static (LocalChatService Service, HttpClient Client) CreateService(HttpMessageHandler handler) + { + var options = Options.Create(new AIOptions + { + LocalChatModel = "qwen2.5-coder:7b", + SystemPrompt = "system prompt" + }); + + var client = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost:11434") + }; + + var httpClientFactory = new Mock(); + httpClientFactory + .Setup(f => f.CreateClient("LocalAIChat")) + .Returns(client); + + var logger = Mock.Of>(); + return (new LocalChatService(options, httpClientFactory.Object, logger), client); + } + + private static HttpResponseMessage CreateJsonResponse(string json) => + new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class RecordingHttpMessageHandler( + List requests, + Queue responses) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var snapshot = new HttpRequestMessage(request.Method, request.RequestUri) + { + Content = request.Content is null + ? null + : new StringContent(await request.Content.ReadAsStringAsync(cancellationToken), Encoding.UTF8, request.Content.Headers.ContentType?.MediaType) + }; + if (request.Headers.Authorization is not null) + { + snapshot.Headers.Authorization = request.Headers.Authorization; + } + + requests.Add(snapshot); + return responses.Dequeue(); + } + } +} diff --git a/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs b/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f4709bfa --- /dev/null +++ b/EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,102 @@ +using EssentialCSharp.Chat.Common.Extensions; +using EssentialCSharp.Chat.Common.Models; +using EssentialCSharp.Chat.Common.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Chat.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Test] + public async Task AddConfiguredChatServices_WithFullAzureConfig_SelectsAzureChatService_AndBindsEmbeddingRetry() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:Endpoint"] = "https://example.openai.azure.com", + ["AIOptions:ChatDeploymentName"] = "chat-deployment", + ["AIOptions:VectorGenerationDeploymentName"] = "embedding-deployment", + ["ConnectionStrings:PostgresVectorStore"] = "Host=localhost;Database=test;Username=test;Password=test", + ["AIOptions:EmbeddingRetry:MaxRetries"] = "7" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationFactory).IsNotNull(); + await Assert.That(services.Any(d => d.ServiceType == typeof(AIChatService))).IsTrue(); + + using var provider = services.BuildServiceProvider(); + var retry = provider.GetRequiredService>().Value; + await Assert.That(retry.MaxRetries).IsEqualTo(7); + } + + [Test] + public async Task AddConfiguredChatServices_WithInvalidAzureEndpoint_FallsBackToLocal_WhenLocalConfigIsValid() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:Endpoint"] = "not-a-valid-uri", + ["AIOptions:ChatDeploymentName"] = "chat-deployment", + ["AIOptions:VectorGenerationDeploymentName"] = "embedding-deployment", + ["ConnectionStrings:PostgresVectorStore"] = "Host=localhost;Database=test;Username=test;Password=test", + ["AIOptions:UseLocalAI"] = "true", + ["AIOptions:LocalEndpoint"] = "http://localhost:11434", + ["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(LocalChatService)); + } + + [Test] + public async Task AddConfiguredChatServices_WithValidLocalConfig_SelectsLocalBackend() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:UseLocalAI"] = "true", + ["AIOptions:LocalEndpoint"] = "http://localhost:11434", + ["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(LocalChatService)); + } + + [Test] + public async Task AddConfiguredChatServices_WithInvalidLocalEndpoint_SelectsUnavailableBackend() + { + var services = CreateServices(new Dictionary + { + ["AIOptions:UseLocalAI"] = "true", + ["AIOptions:LocalEndpoint"] = "invalid-uri", + ["AIOptions:LocalChatModel"] = "qwen2.5-coder:7b" + }); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(UnavailableChatService)); + } + + [Test] + public async Task AddConfiguredChatServices_WithMissingConfig_SelectsUnavailableBackend() + { + var services = CreateServices(new Dictionary()); + + var descriptor = GetChatServiceDescriptor(services); + await Assert.That(descriptor.ImplementationType).IsEqualTo(typeof(UnavailableChatService)); + } + + private static ServiceCollection CreateServices(Dictionary values) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddConfiguredChatServices(configuration); + return services; + } + + private static ServiceDescriptor GetChatServiceDescriptor(IServiceCollection services) => + services.Single(d => d.ServiceType == typeof(IChatCompletionService)); +} diff --git a/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs new file mode 100644 index 00000000..6b5528c4 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ChatAvailabilityTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace EssentialCSharp.Web.Tests; + +[NotInParallel("ChatAvailabilityTests")] +public class ChatAvailabilityTests : IntegrationTestBase +{ + private const string HCaptchaTestToken = "10000000-aaaa-bbbb-cccc-000000000001"; + + [Test] + public async Task ChatMessage_WhenBackendUnavailable_Returns503WithContract() + { + HttpClient client = McpTestHelper.CreateClient(Factory); + + string userId = await McpTestHelper.CreateUserAsync(Factory, "chat-unavailable"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/message") + { + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false, captchaResponse = HCaptchaTestToken }) + }; + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.ServiceUnavailable); + + using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo("chat_unavailable"); + } + + [Test] + public async Task ChatStream_WhenBackendUnavailable_Returns503WithContract() + { + HttpClient client = McpTestHelper.CreateClient(Factory); + + string userId = await McpTestHelper.CreateUserAsync(Factory, "chat-stream-unavailable"); + (string cookieName, string cookieValue) = await McpTestHelper.CreateIdentityApplicationCookieAsync(Factory, userId); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/chat/stream") + { + Content = JsonContent.Create(new { message = "Hello", enableContextualSearch = false, captchaResponse = HCaptchaTestToken }) + }; + McpTestHelper.AddCookie(request, cookieName, cookieValue); + + using HttpResponseMessage response = await client.SendAsync(request); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.ServiceUnavailable); + + using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + await Assert.That(payload.RootElement.GetProperty("errorCode").GetString()).IsEqualTo("chat_unavailable"); + } +} diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index cf7a978e..9d53ccb2 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using EssentialCSharp.Chat.Common.Services; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Services; using TUnit.AspNetCore; @@ -98,6 +99,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddSingleton( _ => TestListingSourceCodeServiceHelper.CreateService()); + + // Override IChatCompletionService with UnavailableChatService so tests are not + // affected by developer AI configuration or environment variables. + services.RemoveAll(); + services.AddSingleton(); }); } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index 998ee7cb..16c16154 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; @@ -68,7 +68,9 @@ public async Task OnPostAsync(string? returnUrl = null) returnUrl ??= Url.Content("~/"); string? captchaToken = Request.Form[CaptchaOptions.HttpPostResponseKeyName]; + HCaptchaResult? captchaResult = await captchaService.VerifyAsync(captchaToken, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (captchaResult?.Success != true) { ModelState.AddModelError(string.Empty, "Human verification failed. Please try again."); diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs index a29d2254..5527cd0c 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -96,6 +96,7 @@ public async Task OnPostAsync(string? returnUrl = null) } HCaptchaResult? response = await captchaService.VerifyAsync(hCaptcha_response, HttpContext.Connection.RemoteIpAddress?.ToString()); + if (response is null) { ModelState.AddModelError(string.Empty, "Captcha verification is temporarily unavailable. Please try again later."); diff --git a/EssentialCSharp.Web/Controllers/ChatController.cs b/EssentialCSharp.Web/Controllers/ChatController.cs index 08f0d29a..02f8539b 100644 --- a/EssentialCSharp.Web/Controllers/ChatController.cs +++ b/EssentialCSharp.Web/Controllers/ChatController.cs @@ -18,17 +18,17 @@ namespace EssentialCSharp.Web.Controllers; [IgnoreAntiforgeryToken] public partial class ChatController : ControllerBase { - private readonly AIChatService _AIChatService; + private readonly IChatCompletionService _ChatService; private readonly ResponseIdValidationService _ResponseIdValidationService; private readonly ICaptchaService _CaptchaService; private readonly CaptchaOptions _CaptchaOptions; private readonly ILogger _Logger; - public ChatController(ILogger logger, AIChatService aiChatService, + public ChatController(ILogger logger, IChatCompletionService chatService, ResponseIdValidationService responseIdValidationService, ICaptchaService captchaService, IOptions captchaOptions) { - _AIChatService = aiChatService; + _ChatService = chatService; _ResponseIdValidationService = responseIdValidationService; _CaptchaService = captchaService; _CaptchaOptions = captchaOptions.Value; @@ -87,9 +87,12 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque if (!_ResponseIdValidationService.ValidateResponseId(userId, previousResponseId)) return BadRequest(new { error = "Invalid conversation context." }); + if (!_ChatService.IsAvailable) + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Chat service unavailable", errorCode = "chat_unavailable" }); + try { - var (response, responseId) = await _AIChatService.GetChatCompletion( + var (response, responseId) = await _ChatService.GetChatCompletion( prompt: request.Message, previousResponseId: previousResponseId, enableContextualSearch: request.EnableContextualSearch, @@ -109,6 +112,10 @@ public async Task SendMessage([FromBody] ChatMessageRequest reque { return BadRequest(new { error = "This conversation has grown too long. Please start a new one.", errorCode = "context_limit_exceeded" }); } + catch (ChatBackendUnavailableException) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Chat service unavailable", errorCode = "chat_unavailable" }); + } } [HttpPost("stream")] @@ -155,13 +162,21 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat return; } + if (!_ChatService.IsAvailable) + { + Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + Response.ContentType = "application/json"; + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = "chat_unavailable" }, CancellationToken.None); + return; + } + Response.ContentType = "text/event-stream"; Response.Headers.CacheControl = "no-cache"; Response.Headers.Connection = "keep-alive"; try { - await foreach (var (text, responseId) in _AIChatService.GetChatCompletionStream( + await foreach (var (text, responseId) in _ChatService.GetChatCompletionStream( prompt: request.Message, previousResponseId: previousResponseId, enableContextualSearch: request.EnableContextualSearch, @@ -247,6 +262,26 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat } } } + catch (ChatBackendUnavailableException ex) when (!Response.HasStarted) + { + LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); + Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + Response.ContentType = "application/json"; + await Response.WriteAsJsonAsync(new { error = "Chat service unavailable", errorCode = "chat_unavailable" }, CancellationToken.None); + } + catch (ChatBackendUnavailableException ex) + { + LogChatStreamErrorMidStream(_Logger, ex, User.Identity?.Name); + try + { + await Response.WriteAsync("data: {\"type\":\"error\",\"message\":\"Chat service unavailable\",\"errorCode\":\"chat_unavailable\"}\n\n", CancellationToken.None); + await Response.Body.FlushAsync(CancellationToken.None); + } + catch (Exception writeException) when (writeException is IOException or OperationCanceledException or ObjectDisposedException) + { + // Best-effort write to an already-streaming response. + } + } catch (Exception ex) when (!Response.HasStarted) { LogChatStreamErrorBeforeResponseStarted(_Logger, ex, User.Identity?.Name); diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index f5c980ad..dc7f0185 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -274,11 +274,8 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddScoped(); - // Add AI Chat services - if (!builder.Environment.IsDevelopment()) - { - builder.Services.AddAzureOpenAIServices(configuration); - } + // Add AI Chat services using configuration-driven backend selection. + builder.Services.AddConfiguredChatServices(configuration); // MCP server — always enabled, authenticated via opaque DB-backed tokens. builder.Services.AddScoped();