Skip to content

Commit c6f7ea6

Browse files
Fix local AI chat backend wiring and fallback handling
1 parent 670b0f5 commit c6f7ea6

13 files changed

Lines changed: 501 additions & 16 deletions

File tree

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace EssentialCSharp.Chat.Common.Extensions;
1515
public static class ServiceCollectionExtensions
1616
{
1717
private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"];
18+
private const string LocalChatHttpClientName = "LocalAIChat";
1819

1920
/// <summary>
2021
/// Adds Azure OpenAI and related AI services to the service collection using Managed Identity
@@ -85,6 +86,71 @@ public static IServiceCollection AddAzureOpenAIServices(
8586
return services;
8687
}
8788

89+
/// <summary>
90+
/// Registers chat services using configuration-driven backend selection.
91+
/// This method never throws for missing or partial AI configuration; it falls back to
92+
/// <see cref="UnavailableChatService"/> so the app can continue running.
93+
/// </summary>
94+
public static IServiceCollection AddConfiguredChatServices(this IServiceCollection services, IConfiguration configuration)
95+
{
96+
services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
97+
98+
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? new AIOptions();
99+
string? postgresConnectionString = configuration.GetConnectionString("PostgresVectorStore");
100+
101+
bool hasAzureEndpoint = !string.IsNullOrWhiteSpace(aiOptions.Endpoint);
102+
bool hasAzureChatDeployment = !string.IsNullOrWhiteSpace(aiOptions.ChatDeploymentName);
103+
bool hasAzureVectorDeployment = !string.IsNullOrWhiteSpace(aiOptions.VectorGenerationDeploymentName);
104+
bool hasAzureConfig = hasAzureEndpoint && hasAzureChatDeployment && hasAzureVectorDeployment && !string.IsNullOrWhiteSpace(postgresConnectionString);
105+
106+
string localEndpoint = ResolveLocalEndpoint(aiOptions, configuration);
107+
bool hasLocalConfig = aiOptions.UseLocalAI
108+
&& !string.IsNullOrWhiteSpace(localEndpoint)
109+
&& !string.IsNullOrWhiteSpace(aiOptions.LocalChatModel);
110+
111+
if (hasAzureConfig)
112+
{
113+
// Pre-validate endpoint URI to avoid exceptions in AddAzureOpenAIServices for
114+
// non-empty but invalid endpoint values.
115+
if (!Uri.TryCreate(aiOptions.Endpoint, UriKind.Absolute, out var azureUri)
116+
|| (azureUri.Scheme != Uri.UriSchemeHttp && azureUri.Scheme != Uri.UriSchemeHttps))
117+
{
118+
Console.Error.WriteLine("[AI] Azure endpoint is not a valid http/https URI. Falling back to local/unavailable.");
119+
}
120+
else
121+
{
122+
services.AddAzureOpenAIServices(aiOptions, postgresConnectionString!);
123+
services.AddSingleton<IChatCompletionService>(provider => provider.GetRequiredService<AIChatService>());
124+
Console.WriteLine("[AI] Selected backend: Azure/Foundry.");
125+
return services;
126+
}
127+
}
128+
129+
if (hasLocalConfig)
130+
{
131+
if (!Uri.TryCreate(localEndpoint, UriKind.Absolute, out var localEndpointUri)
132+
|| (localEndpointUri.Scheme != Uri.UriSchemeHttp && localEndpointUri.Scheme != Uri.UriSchemeHttps))
133+
{
134+
services.AddSingleton<IChatCompletionService, UnavailableChatService>();
135+
Console.Error.WriteLine("[AI] Local backend selected but LocalEndpoint is invalid. Falling back to unavailable backend.");
136+
return services;
137+
}
138+
139+
services.AddHttpClient(LocalChatHttpClientName, client =>
140+
{
141+
client.BaseAddress = localEndpointUri;
142+
client.Timeout = TimeSpan.FromSeconds(120);
143+
});
144+
services.AddSingleton<IChatCompletionService, LocalChatService>();
145+
Console.WriteLine("[AI] Selected backend: Local (Ollama/OpenAI-compatible).");
146+
return services;
147+
}
148+
149+
services.AddSingleton<IChatCompletionService, UnavailableChatService>();
150+
Console.WriteLine("[AI] Selected backend: Unavailable (missing or invalid AI configuration).");
151+
return services;
152+
}
153+
88154
/// <summary>
89155
/// Adds Azure OpenAI and related AI services to the service collection using configuration
90156
/// </summary>
@@ -198,4 +264,14 @@ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity(
198264
return services;
199265
}
200266

267+
private static string ResolveLocalEndpoint(AIOptions options, IConfiguration configuration)
268+
{
269+
if (!string.IsNullOrWhiteSpace(options.LocalEndpoint))
270+
{
271+
return options.LocalEndpoint!;
272+
}
273+
274+
return configuration.GetConnectionString("ollama-chat") ?? string.Empty;
275+
}
276+
201277
}

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: 2 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,7 @@ 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;
2526

2627
public AIChatService(IOptions<AIOptions> options, AISearchService searchService, AzureOpenAIClient azureClient, ILogger<AIChatService> logger)
2728
{
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
Task<(string response, string responseId)> GetChatCompletion(
11+
string prompt,
12+
string? systemPrompt = null,
13+
string? previousResponseId = null,
14+
McpClient? mcpClient = null,
15+
#pragma warning disable OPENAI001
16+
IEnumerable<ResponseTool>? tools = null,
17+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
18+
#pragma warning restore OPENAI001
19+
bool enableContextualSearch = false,
20+
string? endUserId = null,
21+
CancellationToken cancellationToken = default);
22+
23+
IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
24+
string prompt,
25+
string? systemPrompt = null,
26+
string? previousResponseId = null,
27+
McpClient? mcpClient = null,
28+
#pragma warning disable OPENAI001
29+
IEnumerable<ResponseTool>? tools = null,
30+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
31+
#pragma warning restore OPENAI001
32+
bool enableContextualSearch = false,
33+
string? endUserId = null,
34+
CancellationToken cancellationToken = default);
35+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
using System.Collections.Concurrent;
2+
using System.Linq;
3+
using System.Net.Http.Headers;
4+
using System.Text;
5+
using System.Text.Json;
6+
using Microsoft.Extensions.Logging;
7+
using Microsoft.Extensions.Options;
8+
using ModelContextProtocol.Client;
9+
using OpenAI.Responses;
10+
11+
namespace EssentialCSharp.Chat.Common.Services;
12+
13+
public partial class LocalChatService : IChatCompletionService
14+
{
15+
private const int MaxConversationMessages = 20;
16+
private readonly AIOptions _Options;
17+
private readonly IHttpClientFactory _HttpClientFactory;
18+
private readonly ILogger<LocalChatService> _Logger;
19+
private readonly ConcurrentDictionary<string, List<LocalChatMessage>> _ConversationHistory = new();
20+
21+
public bool IsAvailable => true;
22+
23+
public LocalChatService(IOptions<AIOptions> options, IHttpClientFactory httpClientFactory, ILogger<LocalChatService> logger)
24+
{
25+
_Options = options.Value;
26+
_HttpClientFactory = httpClientFactory;
27+
_Logger = logger;
28+
}
29+
30+
public async Task<(string response, string responseId)> GetChatCompletion(
31+
string prompt,
32+
string? systemPrompt = null,
33+
string? previousResponseId = null,
34+
McpClient? mcpClient = null,
35+
#pragma warning disable OPENAI001
36+
IEnumerable<ResponseTool>? tools = null,
37+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
38+
#pragma warning restore OPENAI001
39+
bool enableContextualSearch = false,
40+
string? endUserId = null,
41+
CancellationToken cancellationToken = default)
42+
{
43+
var (client, history, jsonPayload) = PrepareRequest(prompt, systemPrompt, previousResponseId);
44+
45+
HttpResponseMessage response;
46+
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions")
47+
{
48+
Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json")
49+
};
50+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "local-dev-key");
51+
52+
try
53+
{
54+
response = await client.SendAsync(request, cancellationToken);
55+
}
56+
catch (HttpRequestException ex)
57+
{
58+
throw new ChatBackendUnavailableException("Local AI backend is unavailable.", ex);
59+
}
60+
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
61+
{
62+
throw new ChatBackendUnavailableException("Local AI backend timed out.", ex);
63+
}
64+
65+
using (response)
66+
{
67+
var body = await response.Content.ReadAsStringAsync(cancellationToken);
68+
69+
if (!response.IsSuccessStatusCode)
70+
{
71+
LogLocalRequestFailed(_Logger, (int)response.StatusCode, body);
72+
throw new ChatBackendUnavailableException("Local AI backend returned a non-success status.");
73+
}
74+
75+
try
76+
{
77+
var (text, responseId) = ParseResponse(body);
78+
history.Add(new LocalChatMessage("user", prompt));
79+
history.Add(new LocalChatMessage("assistant", text));
80+
_ConversationHistory[responseId] = history.TakeLast(MaxConversationMessages).ToList();
81+
return (text, responseId);
82+
}
83+
catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException)
84+
{
85+
throw new ChatBackendUnavailableException("Local AI backend returned an invalid response.", ex);
86+
}
87+
}
88+
}
89+
90+
public async IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
91+
string prompt,
92+
string? systemPrompt = null,
93+
string? previousResponseId = null,
94+
McpClient? mcpClient = null,
95+
#pragma warning disable OPENAI001
96+
IEnumerable<ResponseTool>? tools = null,
97+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
98+
#pragma warning restore OPENAI001
99+
bool enableContextualSearch = false,
100+
string? endUserId = null,
101+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
102+
{
103+
var (response, responseId) = await GetChatCompletion(
104+
prompt,
105+
systemPrompt,
106+
previousResponseId,
107+
mcpClient,
108+
tools,
109+
reasoningEffortLevel,
110+
enableContextualSearch,
111+
endUserId,
112+
cancellationToken);
113+
114+
if (!string.IsNullOrEmpty(response))
115+
{
116+
yield return (response, responseId: null);
117+
}
118+
119+
yield return (string.Empty, responseId);
120+
}
121+
122+
private (HttpClient Client, List<LocalChatMessage> History, string JsonPayload) PrepareRequest(
123+
string prompt,
124+
string? systemPrompt,
125+
string? previousResponseId = null)
126+
{
127+
var client = _HttpClientFactory.CreateClient("LocalAIChat");
128+
var effectiveSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? _Options.SystemPrompt : systemPrompt;
129+
var history = ResolveHistory(previousResponseId);
130+
131+
var messages = new List<object> { new { role = "system", content = effectiveSystemPrompt } };
132+
messages.AddRange(history.Select(m => new { role = m.Role, content = m.Content }));
133+
messages.Add(new { role = "user", content = prompt });
134+
135+
var payload = new
136+
{
137+
model = _Options.LocalChatModel,
138+
messages,
139+
stream = false
140+
};
141+
142+
return (client, history, JsonSerializer.Serialize(payload));
143+
}
144+
145+
private List<LocalChatMessage> ResolveHistory(string? previousResponseId)
146+
{
147+
if (string.IsNullOrWhiteSpace(previousResponseId))
148+
return [];
149+
150+
return _ConversationHistory.TryGetValue(previousResponseId, out var history)
151+
? [.. history]
152+
: [];
153+
}
154+
155+
private static (string Text, string ResponseId) ParseResponse(string body)
156+
{
157+
using var doc = JsonDocument.Parse(body);
158+
var root = doc.RootElement;
159+
160+
string responseId = root.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String
161+
? idProp.GetString() ?? Guid.NewGuid().ToString("N")
162+
: Guid.NewGuid().ToString("N");
163+
164+
if (root.TryGetProperty("choices", out var choices)
165+
&& choices.ValueKind == JsonValueKind.Array
166+
&& choices.GetArrayLength() > 0)
167+
{
168+
var choice = choices[0];
169+
if (choice.TryGetProperty("message", out var message)
170+
&& message.TryGetProperty("content", out var content)
171+
&& content.ValueKind == JsonValueKind.String)
172+
{
173+
return (content.GetString() ?? string.Empty, responseId);
174+
}
175+
}
176+
177+
if (root.TryGetProperty("message", out var ollamaMessage)
178+
&& ollamaMessage.TryGetProperty("content", out var ollamaContent)
179+
&& ollamaContent.ValueKind == JsonValueKind.String)
180+
{
181+
return (ollamaContent.GetString() ?? string.Empty, responseId);
182+
}
183+
184+
throw new InvalidOperationException("Local AI response did not contain any content.");
185+
}
186+
187+
[LoggerMessage(Level = LogLevel.Warning, Message = "Local chat request failed with status {StatusCode}. Body: {Body}")]
188+
private static partial void LogLocalRequestFailed(ILogger logger, int statusCode, string body);
189+
190+
private sealed record LocalChatMessage(string Role, string Content);
191+
}

0 commit comments

Comments
 (0)