Skip to content

Commit 57bcd60

Browse files
fix: local AI chat backend wiring and fallback handling (#1119)
1 parent d397522 commit 57bcd60

17 files changed

Lines changed: 3999 additions & 12 deletions

EssentialCSharp.Chat.Shared/EssentialCSharp.Chat.Common.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
1010
<PackageReference Include="Microsoft.SemanticKernel" />
1111
<PackageReference Include="Microsoft.SemanticKernel.Connectors.PgVector" />
12+
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
1213
<PackageReference Include="ModelContextProtocol" />
1314
<PackageReference Include="ModelContextProtocol.AspNetCore" />
1415
<PackageReference Include="Microsoft.SourceLink.GitHub">

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.Extensions.AI;
77
using Microsoft.Extensions.Configuration;
88
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Http.Resilience;
910
using Microsoft.Extensions.Options;
1011
using Microsoft.SemanticKernel;
1112
using Npgsql;
@@ -15,6 +16,7 @@ namespace EssentialCSharp.Chat.Common.Extensions;
1516
public static class ServiceCollectionExtensions
1617
{
1718
private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"];
19+
private const string LocalChatHttpClientName = "LocalAIChat";
1820

1921
/// <summary>
2022
/// Adds Azure OpenAI and related AI services to the service collection using Managed Identity
@@ -85,6 +87,82 @@ public static IServiceCollection AddAzureOpenAIServices(
8587
return services;
8688
}
8789

90+
/// <summary>
91+
/// Registers chat services using configuration-driven backend selection.
92+
/// This method never throws for missing or partial AI configuration; it falls back to
93+
/// <see cref="UnavailableChatService"/> so the app can continue running.
94+
/// </summary>
95+
public static IServiceCollection AddConfiguredChatServices(this IServiceCollection services, IConfiguration configuration)
96+
{
97+
services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
98+
99+
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? new AIOptions();
100+
string? postgresConnectionString = configuration.GetConnectionString("PostgresVectorStore");
101+
102+
bool hasAzureEndpoint = !string.IsNullOrWhiteSpace(aiOptions.Endpoint);
103+
bool hasAzureChatDeployment = !string.IsNullOrWhiteSpace(aiOptions.ChatDeploymentName);
104+
bool hasAzureVectorDeployment = !string.IsNullOrWhiteSpace(aiOptions.VectorGenerationDeploymentName);
105+
bool hasAzureConfig = hasAzureEndpoint && hasAzureChatDeployment && hasAzureVectorDeployment
106+
&& IsValidNpgsqlConnectionString(postgresConnectionString);
107+
108+
string localEndpoint = ResolveLocalEndpoint(aiOptions, configuration);
109+
bool hasLocalConfig = !string.IsNullOrWhiteSpace(localEndpoint)
110+
&& !string.IsNullOrWhiteSpace(aiOptions.LocalChatModel);
111+
112+
if (hasAzureConfig)
113+
{
114+
// Pre-validate endpoint URI to avoid exceptions in AddAzureOpenAIServices for
115+
// non-empty but invalid endpoint values.
116+
if (!Uri.TryCreate(aiOptions.Endpoint, UriKind.Absolute, out var azureUri)
117+
|| azureUri.Scheme != Uri.UriSchemeHttps)
118+
{
119+
Console.Error.WriteLine("[AI] Azure endpoint must be a valid https URI. Falling back to local/unavailable.");
120+
}
121+
else
122+
{
123+
services.AddAzureOpenAIServices(aiOptions, postgresConnectionString!);
124+
// Bind EmbeddingRetry from config so operator appsettings/env overrides are honored.
125+
// The AIOptions overload of AddAzureOpenAIServices only registers validation, not config binding.
126+
services.AddOptions<EmbeddingRetryOptions>()
127+
.Bind(configuration.GetSection(EmbeddingRetryOptions.SectionPath));
128+
services.AddSingleton<IChatCompletionService>(provider => provider.GetRequiredService<AIChatService>());
129+
Console.WriteLine("[AI] Selected backend: Azure/Foundry.");
130+
return services;
131+
}
132+
}
133+
134+
if (hasLocalConfig)
135+
{
136+
if (!Uri.TryCreate(localEndpoint, UriKind.Absolute, out var localEndpointUri)
137+
|| (localEndpointUri.Scheme != Uri.UriSchemeHttp && localEndpointUri.Scheme != Uri.UriSchemeHttps))
138+
{
139+
services.AddSingleton<IChatCompletionService, UnavailableChatService>();
140+
Console.Error.WriteLine("[AI] Local backend selected but LocalEndpoint is invalid. Falling back to unavailable backend.");
141+
return services;
142+
}
143+
144+
#pragma warning disable EXTEXP0001
145+
services.AddHttpClient(LocalChatHttpClientName, client =>
146+
{
147+
client.BaseAddress = localEndpointUri;
148+
client.Timeout = TimeSpan.FromSeconds(120);
149+
})
150+
// Disable the global standard resilience handler (set by ConfigureHttpClientDefaults
151+
// in Program.cs). Its default attempt timeout (30s) and total timeout (90s) would
152+
// cut off long local-LLM completions. We set HttpClient.Timeout directly instead.
153+
// Retries are also wrong for LLM calls (non-idempotent, partial responses).
154+
.RemoveAllResilienceHandlers();
155+
#pragma warning restore EXTEXP0001
156+
services.AddSingleton<IChatCompletionService, LocalChatService>();
157+
Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible).");
158+
return services;
159+
}
160+
161+
services.AddSingleton<IChatCompletionService, UnavailableChatService>();
162+
Console.WriteLine("[AI] Selected backend: Unavailable (missing or invalid AI configuration).");
163+
return services;
164+
}
165+
88166
/// <summary>
89167
/// Adds Azure OpenAI and related AI services to the service collection using configuration
90168
/// </summary>
@@ -198,4 +276,34 @@ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity(
198276
return services;
199277
}
200278

279+
private static string ResolveLocalEndpoint(AIOptions options, IConfiguration configuration)
280+
{
281+
if (!string.IsNullOrWhiteSpace(options.LocalEndpoint))
282+
{
283+
return options.LocalEndpoint!;
284+
}
285+
286+
return configuration.GetConnectionString("ollama-chat") ?? string.Empty;
287+
}
288+
289+
/// <summary>
290+
/// Returns true if <paramref name="connectionString"/> can be parsed by
291+
/// <see cref="NpgsqlConnectionStringBuilder"/> and resolves to a non-empty Host.
292+
/// Rejects null, empty, and placeholder strings like "your-postgres-connection-string-here".
293+
/// </summary>
294+
private static bool IsValidNpgsqlConnectionString(string? connectionString)
295+
{
296+
if (string.IsNullOrWhiteSpace(connectionString))
297+
return false;
298+
try
299+
{
300+
var builder = new NpgsqlConnectionStringBuilder(connectionString);
301+
return !string.IsNullOrWhiteSpace(builder.Host);
302+
}
303+
catch (ArgumentException)
304+
{
305+
return false;
306+
}
307+
}
308+
201309
}

EssentialCSharp.Chat.Shared/Models/AIOptions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
public class AIOptions
44
{
5+
/// <summary>
6+
/// Enables local AI backend usage when Azure endpoint is not configured.
7+
/// </summary>
8+
public bool UseLocalAI { get; set; }
9+
10+
/// <summary>
11+
/// Local OpenAI-compatible endpoint (for example, Ollama).
12+
/// </summary>
13+
public string? LocalEndpoint { get; set; }
14+
15+
/// <summary>
16+
/// Local chat model identifier (for example, qwen2.5-coder:7b).
17+
/// </summary>
18+
public string? LocalChatModel { get; set; }
19+
520
/// <summary>
621
/// The Azure OpenAI deployment name for text embedding generation.
722
/// </summary>

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace EssentialCSharp.Chat.Common.Services;
1212
/// <summary>
1313
/// Service for handling AI chat completions using the OpenAI Responses API
1414
/// </summary>
15-
public partial class AIChatService
15+
public partial class AIChatService : IChatCompletionService
1616
{
1717
private readonly AIOptions _Options;
1818
private readonly AzureOpenAIClient _AzureClient;
@@ -22,6 +22,8 @@ public partial class AIChatService
2222
private readonly AISearchService _SearchService;
2323
private readonly ILogger<AIChatService> _Logger;
2424
private readonly FrozenSet<string> _AllowedMcpTools;
25+
public bool IsAvailable => true;
26+
public bool SupportsContextualSearch => true;
2527

2628
public AIChatService(IOptions<AIOptions> options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger<AIChatService> logger)
2729
{
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
namespace EssentialCSharp.Chat.Common.Services;
2+
3+
public class ChatBackendUnavailableException(string message, Exception? innerException = null)
4+
: Exception(message, innerException);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using ModelContextProtocol.Client;
2+
using OpenAI.Responses;
3+
4+
namespace EssentialCSharp.Chat.Common.Services;
5+
6+
public interface IChatCompletionService
7+
{
8+
bool IsAvailable { get; }
9+
10+
bool SupportsContextualSearch { get; }
11+
12+
Task<(string response, string responseId)> GetChatCompletion(
13+
string prompt,
14+
string? systemPrompt = null,
15+
string? previousResponseId = null,
16+
McpClient? mcpClient = null,
17+
#pragma warning disable OPENAI001
18+
IEnumerable<ResponseTool>? tools = null,
19+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
20+
#pragma warning restore OPENAI001
21+
bool enableContextualSearch = false,
22+
string? endUserId = null,
23+
CancellationToken cancellationToken = default);
24+
25+
IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
26+
string prompt,
27+
string? systemPrompt = null,
28+
string? previousResponseId = null,
29+
McpClient? mcpClient = null,
30+
#pragma warning disable OPENAI001
31+
IEnumerable<ResponseTool>? tools = null,
32+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
33+
#pragma warning restore OPENAI001
34+
bool enableContextualSearch = false,
35+
string? endUserId = null,
36+
CancellationToken cancellationToken = default);
37+
}

0 commit comments

Comments
 (0)