66using Microsoft . Extensions . AI ;
77using Microsoft . Extensions . Configuration ;
88using Microsoft . Extensions . DependencyInjection ;
9+ using Microsoft . Extensions . Http . Resilience ;
910using Microsoft . Extensions . Options ;
1011using Microsoft . SemanticKernel ;
1112using Npgsql ;
@@ -15,6 +16,7 @@ namespace EssentialCSharp.Chat.Common.Extensions;
1516public 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}
0 commit comments