Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.PgVector" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.AspNetCore" />
<PackageReference Include="Microsoft.SourceLink.GitHub">
Expand Down
109 changes: 109 additions & 0 deletions EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";

/// <summary>
/// Adds Azure OpenAI and related AI services to the service collection using Managed Identity
Expand Down Expand Up @@ -85,6 +87,83 @@ public static IServiceCollection AddAzureOpenAIServices(
return services;
}

/// <summary>
/// Registers chat services using configuration-driven backend selection.
/// This method never throws for missing or partial AI configuration; it falls back to
/// <see cref="UnavailableChatService"/> so the app can continue running.
/// </summary>
public static IServiceCollection AddConfiguredChatServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AIOptions>(configuration.GetSection("AIOptions"));

var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? 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);

Comment thread
BenjaminMichaelis marked this conversation as resolved.
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
{
Comment thread
BenjaminMichaelis marked this conversation as resolved.
services.AddAzureOpenAIServices(aiOptions, postgresConnectionString!);
Comment thread
BenjaminMichaelis marked this conversation as resolved.
// Bind EmbeddingRetry from config so operator appsettings/env overrides are honored.
// The AIOptions overload of AddAzureOpenAIServices only registers validation, not config binding.
services.AddOptions<EmbeddingRetryOptions>()
.Bind(configuration.GetSection(EmbeddingRetryOptions.SectionPath));
services.AddSingleton<IChatCompletionService>(provider => provider.GetRequiredService<AIChatService>());
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<IChatCompletionService, UnavailableChatService>();
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<IChatCompletionService, LocalChatService>();
Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible).");
return services;
}

services.AddSingleton<IChatCompletionService, UnavailableChatService>();
Console.WriteLine("[AI] Selected backend: Unavailable (missing or invalid AI configuration).");
return services;
}

/// <summary>
/// Adds Azure OpenAI and related AI services to the service collection using configuration
/// </summary>
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Returns true if <paramref name="connectionString"/> can be parsed by
/// <see cref="NpgsqlConnectionStringBuilder"/> and resolves to a non-empty Host.
/// Rejects null, empty, and placeholder strings like "your-postgres-connection-string-here".
/// </summary>
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;
}
}

}
15 changes: 15 additions & 0 deletions EssentialCSharp.Chat.Shared/Models/AIOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

public class AIOptions
{
/// <summary>
/// Enables local AI backend usage when Azure endpoint is not configured.
/// </summary>
public bool UseLocalAI { get; set; }

/// <summary>
/// Local OpenAI-compatible endpoint (for example, Ollama).
/// </summary>
public string? LocalEndpoint { get; set; }

/// <summary>
/// Local chat model identifier (for example, qwen2.5-coder:7b).
/// </summary>
public string? LocalChatModel { get; set; }

/// <summary>
/// The Azure OpenAI deployment name for text embedding generation.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion EssentialCSharp.Chat.Shared/Services/AIChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace EssentialCSharp.Chat.Common.Services;
/// <summary>
/// Service for handling AI chat completions using the OpenAI Responses API
/// </summary>
public partial class AIChatService
public partial class AIChatService : IChatCompletionService
{
private readonly AIOptions _Options;
private readonly AzureOpenAIClient _AzureClient;
Expand All @@ -22,6 +22,7 @@ public partial class AIChatService
private readonly AISearchService _SearchService;
private readonly ILogger<AIChatService> _Logger;
private readonly FrozenSet<string> _AllowedMcpTools;
public bool IsAvailable => true;

public AIChatService(IOptions<AIOptions> options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger<AIChatService> logger)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace EssentialCSharp.Chat.Common.Services;

public class ChatBackendUnavailableException(string message, Exception? innerException = null)
: Exception(message, innerException);
35 changes: 35 additions & 0 deletions EssentialCSharp.Chat.Shared/Services/IChatCompletionService.cs
Original file line number Diff line number Diff line change
@@ -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<ResponseTool>? 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<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
#pragma warning restore OPENAI001
bool enableContextualSearch = false,
string? endUserId = null,
CancellationToken cancellationToken = default);
}
Loading
Loading