Skip to content

Commit 418eeac

Browse files
upfdtae
1 parent afe7714 commit 418eeac

16 files changed

Lines changed: 412 additions & 38 deletions

File tree

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,35 @@ public static class ServiceCollectionExtensions
1616
private static readonly string[] _PostgresScopes = ["https://ossrdbms-aad.database.windows.net/.default"];
1717

1818
/// <summary>
19-
/// Dispatches to <see cref="AddLocalAIServices"/> or <see cref="AddAzureOpenAIServices"/>
20-
/// based on <c>AIOptions:UseLocalAI</c>. AI chat requires either local AI mode
21-
/// or a configured Azure/Foundry endpoint in every environment.
19+
/// Resolves the AI mode once and applies environment-specific enforcement.
20+
/// Development allows Disabled, Local, or Azure modes. Non-Development requires Azure.
2221
/// </summary>
2322
public static IHostApplicationBuilder AddAIServices(
2423
this IHostApplicationBuilder builder,
25-
IConfiguration configuration)
24+
IConfiguration configuration,
25+
AIConfigurationState? aiConfigurationState = null)
2626
{
2727
builder.Services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
28-
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? new AIOptions();
28+
aiConfigurationState ??= AIConfigurationState.From(configuration.GetSection("AIOptions").Get<AIOptions>());
29+
builder.Services.AddSingleton(aiConfigurationState);
2930

30-
if (aiOptions.UseLocalAI)
31+
if (!builder.Environment.IsDevelopment() && !aiConfigurationState.UsesAzureAI)
32+
{
33+
throw new InvalidOperationException(
34+
"Non-Development environments require AIOptions:Endpoint to be configured. Local AI is only supported in Development.");
35+
}
36+
37+
if (aiConfigurationState.UsesLocalAI)
3138
{
3239
builder.AddLocalAIServices(configuration);
3340
}
34-
else if (!string.IsNullOrEmpty(aiOptions.Endpoint))
41+
else if (aiConfigurationState.UsesAzureAI)
3542
{
3643
builder.Services.AddAzureOpenAIServices(configuration);
3744
}
3845
else
3946
{
40-
throw new InvalidOperationException(
41-
"AI chat requires either AIOptions:UseLocalAI=true or AIOptions:Endpoint to be configured.");
47+
builder.Services.AddSingleton<IAIChatService, UnavailableAIChatService>();
4248
}
4349

4450
return builder;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace EssentialCSharp.Chat;
2+
3+
public enum AIServiceMode
4+
{
5+
Disabled,
6+
Local,
7+
Azure
8+
}
9+
10+
public sealed record AIConfigurationState(AIServiceMode Mode)
11+
{
12+
public const string DevelopmentUnavailableMessage =
13+
"AI chat is unavailable for this local run. Start the site with Aspire local AI or configure Azure AI to enable chat.";
14+
15+
public bool IsAvailable => Mode is AIServiceMode.Local or AIServiceMode.Azure;
16+
public bool IsDisabled => Mode == AIServiceMode.Disabled;
17+
public bool UsesLocalAI => Mode == AIServiceMode.Local;
18+
public bool UsesAzureAI => Mode == AIServiceMode.Azure;
19+
20+
public static AIConfigurationState From(AIOptions? options)
21+
{
22+
options ??= new AIOptions();
23+
24+
if (!string.IsNullOrWhiteSpace(options.Endpoint))
25+
{
26+
return new(AIServiceMode.Azure);
27+
}
28+
29+
if (options.UseLocalAI)
30+
{
31+
return new(AIServiceMode.Local);
32+
}
33+
34+
return new(AIServiceMode.Disabled);
35+
}
36+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace EssentialCSharp.Chat.Common.Services;
2+
3+
public sealed class AIChatUnavailableException(string message) : InvalidOperationException(message);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using ModelContextProtocol.Client;
2+
using OpenAI.Responses;
3+
4+
namespace EssentialCSharp.Chat.Common.Services;
5+
6+
public sealed class UnavailableAIChatService : IAIChatService
7+
{
8+
private static AIChatUnavailableException CreateException() =>
9+
new(AIConfigurationState.DevelopmentUnavailableMessage);
10+
11+
public Task<(string response, string responseId)> GetChatCompletion(
12+
string prompt,
13+
string? systemPrompt = null,
14+
string? previousResponseId = null,
15+
IMcpClient? mcpClient = null,
16+
#pragma warning disable OPENAI001
17+
IEnumerable<ResponseTool>? tools = null,
18+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
19+
#pragma warning restore OPENAI001
20+
bool enableContextualSearch = false,
21+
CancellationToken cancellationToken = default) =>
22+
Task.FromException<(string response, string responseId)>(CreateException());
23+
24+
public IAsyncEnumerable<(string text, string? responseId)> GetChatCompletionStream(
25+
string prompt,
26+
string? systemPrompt = null,
27+
string? previousResponseId = null,
28+
IMcpClient? mcpClient = null,
29+
#pragma warning disable OPENAI001
30+
IEnumerable<ResponseTool>? tools = null,
31+
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
32+
#pragma warning restore OPENAI001
33+
bool enableContextualSearch = false,
34+
CancellationToken cancellationToken = default) =>
35+
ThrowUnavailable(cancellationToken);
36+
37+
private static async IAsyncEnumerable<(string text, string? responseId)> ThrowUnavailable(
38+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
39+
{
40+
cancellationToken.ThrowIfCancellationRequested();
41+
throw CreateException();
42+
#pragma warning disable CS0162
43+
yield break;
44+
#pragma warning restore CS0162
45+
}
46+
}

EssentialCSharp.Chat.Tests/ServiceCollectionExtensionsTests.cs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ namespace EssentialCSharp.Chat.Tests;
99
public class ServiceCollectionExtensionsTests
1010
{
1111
[Test]
12-
public async Task AddAIServices_WhenDevelopmentWithoutConfiguration_ThrowsInvalidOperationException()
12+
public async Task AddAIServices_WhenDevelopmentWithoutConfiguration_RegistersUnavailableAIService()
1313
{
1414
var builder = CreateBuilder(Environments.Development);
1515

16-
await Assert.That(() => builder.AddAIServices(builder.Configuration))
17-
.Throws<InvalidOperationException>();
16+
builder.AddAIServices(builder.Configuration);
17+
18+
var descriptor = builder.Services.LastOrDefault(service => service.ServiceType == typeof(IAIChatService));
19+
await Assert.That(descriptor).IsNotNull();
20+
await Assert.That(descriptor!.ImplementationType).IsEqualTo(typeof(UnavailableAIChatService));
1821
}
1922

2023
[Test]
@@ -54,6 +57,27 @@ public async Task AddAIServices_WhenAzureEndpointConfigured_RegistersAzureAIServ
5457
await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(IAIChatService))).IsTrue();
5558
}
5659

60+
[Test]
61+
public async Task AddAIServices_WhenEndpointAndUseLocalAIConfigured_UsesAzureAI()
62+
{
63+
var builder = CreateBuilder(
64+
Environments.Development,
65+
new Dictionary<string, string?>
66+
{
67+
["AIOptions:UseLocalAI"] = bool.TrueString,
68+
["AIOptions:Endpoint"] = "https://example.openai.azure.com/",
69+
["AIOptions:ChatDeploymentName"] = "chat",
70+
["AIOptions:VectorGenerationDeploymentName"] = "embeddings",
71+
["ConnectionStrings:PostgresVectorStore"] = "Host=test.postgres.database.azure.com;Database=app;Username=user",
72+
["ConnectionStrings:ollama-chat"] = "Endpoint=http://localhost:11434;Model=qwen2.5-coder:7b"
73+
});
74+
75+
builder.AddAIServices(builder.Configuration);
76+
77+
await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(AIChatService))).IsTrue();
78+
await Assert.That(builder.Services.Any(service => service.ImplementationType == typeof(LocalAIChatService))).IsFalse();
79+
}
80+
5781
[Test]
5882
public async Task AddAIServices_WhenProductionWithoutConfiguration_ThrowsInvalidOperationException()
5983
{
@@ -63,6 +87,20 @@ await Assert.That(() => builder.AddAIServices(builder.Configuration))
6387
.Throws<InvalidOperationException>();
6488
}
6589

90+
[Test]
91+
public async Task AddAIServices_WhenProductionUsesLocalAI_ThrowsInvalidOperationException()
92+
{
93+
var builder = CreateBuilder(
94+
Environments.Production,
95+
new Dictionary<string, string?>
96+
{
97+
["AIOptions:UseLocalAI"] = bool.TrueString
98+
});
99+
100+
await Assert.That(() => builder.AddAIServices(builder.Configuration))
101+
.Throws<InvalidOperationException>();
102+
}
103+
66104
private static HostApplicationBuilder CreateBuilder(
67105
string environmentName,
68106
Dictionary<string, string?>? settings = null)

EssentialCSharp.Web.Tests/ChatControllerTests.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Security.Claims;
22
using System.Text.Json;
3+
using EssentialCSharp.Chat;
34
using EssentialCSharp.Chat.Common.Services;
45
using EssentialCSharp.Web.Controllers;
56
using EssentialCSharp.Web.Models;
@@ -59,9 +60,38 @@ public async Task StreamMessage_CaptchaServiceUnavailable_Returns503WithCaptchaU
5960
await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_unavailable");
6061
}
6162

63+
[Test]
64+
public async Task SendMessage_WhenAIUnavailable_Returns503WithAIUnavailable()
65+
{
66+
var controller = CreateController(aiConfiguration: new AIConfigurationState(AIServiceMode.Disabled));
67+
68+
var result = await controller.SendMessage(new ChatMessageRequest { Message = "hello" });
69+
70+
await Assert.That(result).IsTypeOf<ObjectResult>();
71+
var objectResult = (ObjectResult)result;
72+
await Assert.That(objectResult.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable);
73+
74+
var payload = JsonSerializer.Serialize(objectResult.Value);
75+
var body = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(payload)!;
76+
await Assert.That(body["errorCode"].GetString()).IsEqualTo("ai_unavailable");
77+
}
78+
79+
[Test]
80+
public async Task StreamMessage_WhenAIUnavailable_Returns503WithAIUnavailable()
81+
{
82+
var controller = CreateController(aiConfiguration: new AIConfigurationState(AIServiceMode.Disabled));
83+
84+
await controller.StreamMessage(new ChatMessageRequest { Message = "hello" });
85+
86+
var body = await ReadJsonResponse(controller.HttpContext.Response);
87+
await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable);
88+
await Assert.That(body["errorCode"].GetString()).IsEqualTo("ai_unavailable");
89+
}
90+
6291
private static ChatController CreateController(
6392
IAIChatService? aiChatService = null,
64-
ICaptchaService? captchaService = null)
93+
ICaptchaService? captchaService = null,
94+
AIConfigurationState? aiConfiguration = null)
6595
{
6696
var httpContext = new DefaultHttpContext
6797
{
@@ -71,6 +101,7 @@ private static ChatController CreateController(
71101

72102
var controller = new ChatController(
73103
Mock.Of<ILogger<ChatController>>(),
104+
aiConfiguration ?? new AIConfigurationState(AIServiceMode.Local),
74105
aiChatService ?? new Mock<IAIChatService>(MockBehavior.Strict).Object,
75106
captchaService ?? new Mock<ICaptchaService>(MockBehavior.Strict).Object)
76107
{

EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using EssentialCSharp.Web.Areas.Identity.Data;
1+
using EssentialCSharp.Web.Areas.Identity.Data;
2+
using Microsoft.AspNetCore.Identity;
23
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
34
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
45
using Microsoft.EntityFrameworkCore;
@@ -13,5 +14,17 @@ public class EssentialCSharpWebContext(DbContextOptions<EssentialCSharpWebContex
1314
protected override void OnModelCreating(ModelBuilder builder)
1415
{
1516
base.OnModelCreating(builder);
17+
18+
builder.Entity<IdentityUserLogin<string>>(login =>
19+
{
20+
login.Property(entry => entry.LoginProvider).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength);
21+
login.Property(entry => entry.ProviderKey).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength);
22+
});
23+
24+
builder.Entity<IdentityUserToken<string>>(token =>
25+
{
26+
token.Property(entry => entry.LoginProvider).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength);
27+
token.Property(entry => entry.Name).HasMaxLength(EssentialCSharpWebIdentitySchema.KeyMaxLength);
28+
});
1629
}
1730
}

0 commit comments

Comments
 (0)