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