diff --git a/copilot-cli/.github/instructions/commiting.instructions.md b/copilot-cli/.github/instructions/commiting.instructions.md index 1d805895..984f4b29 100644 --- a/copilot-cli/.github/instructions/commiting.instructions.md +++ b/copilot-cli/.github/instructions/commiting.instructions.md @@ -35,6 +35,45 @@ npm install npm run build ``` +## Local Development Guidelines + +**When working locally (CLI use only), never commit changes directly.** + +1. **Keep changes local** + - All experimental or temporary modifications must remain on your machine. + - Do not merge or push to the shared repository. + +2. **Use local configuration overrides** + - Store environment-specific settings in `appsettings.Development.json` or environment variables. + - Avoid editing shared configuration files. + +3. **Isolate experiments** + - Test code in separate modules, branches, or projects. + - Avoid breaking the main solution or CI/CD pipelines. + +4. **Cleanup after local testing** + - Revert temporary code changes before switching branches. + - Remove unused assets, build outputs, or temporary files. + +5. **Document local changes** + - Maintain a local log of experimental changes (`LOCAL-DEV-CHANGES.md`) if necessary. + - Never commit this file to the repo. + +6. **Offline testing** + - Focus on asset builds, static analysis, and unit tests that do not require external network dependencies. + - Document any network-dependent features for later testing. + +--- + +## Coding Standards and Conventions + +- Follow .editorconfig for C# naming and formatting rules +- Use async/await, dependency injection, ILogger for logging +- Seal classes by default except for ViewModels used by Orchard Core display drivers +- Avoid static mutable state, hardcoded secrets, synchronous I/O, and `DateTime.UtcNow` + +--- + ## Working rules - Keep changes focused on `CrestApps.Core` diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs index a427ad85..2190173e 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs @@ -20,6 +20,7 @@ public sealed class ConfigurationAIDeploymentSource : INamedSourceCatalogSource< private readonly TimeProvider _timeProvider; private readonly AIOptions _aiOptions; private readonly AIDeploymentCatalogOptions _catalogOptions; + private readonly AIProviderConnectionCatalogOptions _connectionCatalogOptions; private readonly ILogger _logger; /// @@ -29,18 +30,21 @@ public sealed class ConfigurationAIDeploymentSource : INamedSourceCatalogSource< /// The time provider. /// The ai options. /// The catalog options. + /// The connection catalog options. /// The logger. public ConfigurationAIDeploymentSource( IConfiguration configuration, TimeProvider timeProvider, IOptions aiOptions, IOptions catalogOptions, + IOptions connectionCatalogOptions, ILogger logger) { _configuration = configuration; _timeProvider = timeProvider; _aiOptions = aiOptions.Value; _catalogOptions = catalogOptions.Value; + _connectionCatalogOptions = connectionCatalogOptions.Value; _logger = logger; } @@ -72,6 +76,7 @@ public ValueTask> GetEntriesAsync(IReadOnlyCol try { ReadConfiguredDeployments(deployments, names); + ReadConnectionDeployments(deployments, names); } catch (Exception ex) { @@ -137,6 +142,127 @@ private void ReadConfiguredDeployments(Dictionary deployme } } + private void ReadConnectionDeployments(Dictionary deployments, Dictionary names) + { + foreach (var sectionPath in _connectionCatalogOptions.ConnectionSections) + { + var section = _configuration.GetSection(sectionPath); + + if (!section.Exists()) + { + continue; + } + + foreach (var connectionSection in section.GetChildren()) + { + var clientName = AIProviderNameNormalizer.Normalize( + connectionSection["ClientName"] ?? connectionSection["ProviderName"]); + var connectionName = connectionSection["Name"] ?? connectionSection.Key; + + ReadConnectionDeploymentNames(connectionSection, clientName, connectionName, deployments, names, sectionPath); + } + } + + foreach (var sectionPath in _connectionCatalogOptions.ProviderSections) + { + var section = _configuration.GetSection(sectionPath); + + if (!section.Exists()) + { + continue; + } + + foreach (var providerSection in section.GetChildren()) + { + var providerName = AIProviderNameNormalizer.Normalize(providerSection.Key); + var connectionsSection = providerSection.GetSection("Connections"); + + if (!connectionsSection.Exists()) + { + continue; + } + + foreach (var connectionSection in connectionsSection.GetChildren()) + { + var connectionName = connectionSection["Name"] ?? connectionSection.Key; + + ReadConnectionDeploymentNames(connectionSection, providerName, connectionName, deployments, names, sectionPath); + } + } + } + } + + private void ReadConnectionDeploymentNames( + IConfigurationSection connectionSection, + string clientName, + string connectionName, + Dictionary deployments, + Dictionary names, + string sectionPath) + { + if (string.IsNullOrWhiteSpace(clientName) || string.IsNullOrWhiteSpace(connectionName)) + { + return; + } + + if (!_aiOptions.Deployments.ContainsKey(clientName)) + { + return; + } + + var chatDeploymentName = connectionSection["ChatDeploymentName"] + ?? connectionSection["DeploymentName"] + ?? connectionSection["DefaultChatDeploymentName"] + ?? connectionSection["DefaultDeploymentName"]; + + var embeddingDeploymentName = connectionSection["EmbeddingDeploymentName"] + ?? connectionSection["DefaultEmbeddingDeploymentName"]; + + var imagesDeploymentName = connectionSection["ImagesDeploymentName"] + ?? connectionSection["DefaultImagesDeploymentName"]; + + var speechToTextDeploymentName = connectionSection["SpeechToTextDeploymentName"] + ?? connectionSection["DefaultSpeechToTextDeploymentName"]; + + var utilityDeploymentName = connectionSection["UtilityDeploymentName"] + ?? connectionSection["DefaultUtilityDeploymentName"]; + + AddConnectionDeployment(deployments, names, clientName, connectionName, chatDeploymentName, AIDeploymentType.Chat | AIDeploymentType.Utility, sectionPath); + AddConnectionDeployment(deployments, names, clientName, connectionName, utilityDeploymentName, AIDeploymentType.Utility, sectionPath); + AddConnectionDeployment(deployments, names, clientName, connectionName, embeddingDeploymentName, AIDeploymentType.Embedding, sectionPath); + AddConnectionDeployment(deployments, names, clientName, connectionName, imagesDeploymentName, AIDeploymentType.Image, sectionPath); + AddConnectionDeployment(deployments, names, clientName, connectionName, speechToTextDeploymentName, AIDeploymentType.SpeechToText, sectionPath); + } + + private void AddConnectionDeployment( + Dictionary deployments, + Dictionary names, + string clientName, + string connectionName, + string deploymentName, + AIDeploymentType type, + string sectionPath) + { + if (string.IsNullOrWhiteSpace(deploymentName)) + { + return; + } + + var deployment = new AIDeployment + { + ItemId = AIConfigurationRecordIds.CreateDeploymentId(clientName, connectionName, deploymentName), + Name = deploymentName, + ModelName = deploymentName, + Source = clientName, + ConnectionName = connectionName, + Type = type, + IsReadOnly = true, + CreatedUtc = _timeProvider.GetUtcNow().DateTime, + }; + + AddDeployment(deployments, names, deployment, sectionPath); + } + private void ReadConfiguredDeploymentsFromArray(JsonArray deploymentArray, Dictionary deployments, Dictionary names, string sectionPath) { if (_logger.IsEnabled(LogLevel.Debug)) @@ -290,6 +416,20 @@ private void AddDeployment( return; } + if (deployments.ContainsKey(deployment.ItemId)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipping AI deployment '{DeploymentName}' from '{SourceDescription}' because ItemId '{DeploymentId}' is already registered.", + deployment.Name, + sourceDescription, + deployment.ItemId); + } + + return; + } + names[deployment.Name] = deployment.ItemId; deployments[deployment.ItemId] = deployment; diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs index 0fd15736..a3c3b6df 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs @@ -265,10 +265,143 @@ public async Task GetAllAsync_ShouldPreferStoredDeploymentWhenConfiguredNameConf Assert.Equal("ui-deployment", deployments.Single().ItemId); } + [Fact] + public async Task GetAllAsync_ShouldCreateDeploymentsFromProviderSectionConnectionDeploymentNames() + { + // Arrange + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["CrestApps:AI:Providers:Azure:Connections:test1:Endpoint"] = "https://test1.openai.azure.com/", + ["CrestApps:AI:Providers:Azure:Connections:test1:AuthenticationType"] = "ApiKey", + ["CrestApps:AI:Providers:Azure:Connections:test1:ApiKey"] = "secret", + ["CrestApps:AI:Providers:Azure:Connections:test1:DefaultDeploymentName"] = "gpt-4.1-mini", + ["CrestApps:AI:Providers:Azure:Connections:test1:DefaultEmbeddingDeploymentName"] = "text-embedding-3-small", + }).Build(); + + var aiOptions = new AIOptions(); + aiOptions.AddDeploymentProvider(AzureOpenAIConstants.ClientName); + var store = CreateStore(configuration, aiOptions); + + // Act + var deployments = await store.GetAllAsync(TestContext.Current.CancellationToken); + + // Assert + var chatDeployment = Assert.Single(deployments, d => d.Name == "gpt-4.1-mini"); + Assert.Equal(AzureOpenAIConstants.ClientName, chatDeployment.ClientName); + Assert.Equal("test1", chatDeployment.ConnectionName); + Assert.Equal(AIDeploymentType.Chat | AIDeploymentType.Utility, chatDeployment.Type); + Assert.True(chatDeployment.IsReadOnly); + + var embeddingDeployment = Assert.Single(deployments, d => d.Name == "text-embedding-3-small"); + Assert.Equal(AzureOpenAIConstants.ClientName, embeddingDeployment.ClientName); + Assert.Equal("test1", embeddingDeployment.ConnectionName); + Assert.Equal(AIDeploymentType.Embedding, embeddingDeployment.Type); + Assert.True(embeddingDeployment.IsReadOnly); + } + + [Fact] + public async Task GetAllAsync_ShouldCreateDeploymentsFromTopLevelConnectionSectionDeploymentNames() + { + // Arrange + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["CrestApps:AI:Connections:0:Name"] = "my-openai", + ["CrestApps:AI:Connections:0:ClientName"] = "OpenAI", + ["CrestApps:AI:Connections:0:DefaultDeploymentName"] = "gpt-4.1", + ["CrestApps:AI:Connections:0:DefaultEmbeddingDeploymentName"] = "text-embedding-3-large", + }).Build(); + + var aiOptions = new AIOptions(); + aiOptions.AddDeploymentProvider("OpenAI"); + var store = CreateStore(configuration, aiOptions); + + // Act + var deployments = await store.GetAllAsync(TestContext.Current.CancellationToken); + + // Assert + var chatDeployment = Assert.Single(deployments, d => d.Name == "gpt-4.1"); + Assert.Equal("OpenAI", chatDeployment.ClientName); + Assert.Equal("my-openai", chatDeployment.ConnectionName); + Assert.Equal(AIDeploymentType.Chat | AIDeploymentType.Utility, chatDeployment.Type); + Assert.True(chatDeployment.IsReadOnly); + + var embeddingDeployment = Assert.Single(deployments, d => d.Name == "text-embedding-3-large"); + Assert.Equal("OpenAI", embeddingDeployment.ClientName); + Assert.Equal("my-openai", embeddingDeployment.ConnectionName); + Assert.Equal(AIDeploymentType.Embedding, embeddingDeployment.Type); + Assert.True(embeddingDeployment.IsReadOnly); + } + + [Fact] + public async Task GetAllAsync_ShouldCreateDeploymentsFromCustomProviderSections() + { + // Arrange + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["CrestApps:CrestApps_AI:Providers:Azure:Connections:test1:Endpoint"] = "https://test1.openai.azure.com/", + ["CrestApps:CrestApps_AI:Providers:Azure:Connections:test1:DefaultDeploymentName"] = "gpt-4.1-mini", + ["CrestApps:CrestApps_AI:Providers:Azure:Connections:test1:DefaultEmbeddingDeploymentName"] = "text-embedding-3-small", + }).Build(); + + var aiOptions = new AIOptions(); + aiOptions.AddDeploymentProvider(AzureOpenAIConstants.ClientName); + + var connectionCatalogOptions = new AIProviderConnectionCatalogOptions(); + connectionCatalogOptions.ProviderSections.Add("CrestApps:CrestApps_AI:Providers"); + + var store = CreateStore(configuration, aiOptions, connectionCatalogOptions: connectionCatalogOptions); + + // Act + var deployments = await store.GetAllAsync(TestContext.Current.CancellationToken); + + // Assert + var chatDeployment = Assert.Single(deployments, d => d.Name == "gpt-4.1-mini"); + Assert.Equal(AzureOpenAIConstants.ClientName, chatDeployment.ClientName); + Assert.Equal("test1", chatDeployment.ConnectionName); + Assert.Equal(AIDeploymentType.Chat | AIDeploymentType.Utility, chatDeployment.Type); + Assert.True(chatDeployment.IsReadOnly); + + var embeddingDeployment = Assert.Single(deployments, d => d.Name == "text-embedding-3-small"); + Assert.Equal(AzureOpenAIConstants.ClientName, embeddingDeployment.ClientName); + Assert.Equal("test1", embeddingDeployment.ConnectionName); + Assert.Equal(AIDeploymentType.Embedding, embeddingDeployment.Type); + Assert.True(embeddingDeployment.IsReadOnly); + } + + [Fact] + public async Task GetAllAsync_ShouldNotDuplicateConnectionDeploymentsAlreadyInExplicitSection() + { + // Arrange + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["CrestApps:AI:Deployments:0:ClientName"] = "Azure", + ["CrestApps:AI:Deployments:0:ConnectionName"] = "test1", + ["CrestApps:AI:Deployments:0:Name"] = "gpt-4.1-mini", + ["CrestApps:AI:Deployments:0:Type"] = "Chat", + ["CrestApps:AI:Providers:Azure:Connections:test1:DefaultDeploymentName"] = "gpt-4.1-mini", + ["CrestApps:AI:Providers:Azure:Connections:test1:DefaultEmbeddingDeploymentName"] = "text-embedding-3-small", + }).Build(); + + var aiOptions = new AIOptions(); + aiOptions.AddDeploymentProvider(AzureOpenAIConstants.ClientName); + var store = CreateStore(configuration, aiOptions); + + // Act + var deployments = await store.GetAllAsync(TestContext.Current.CancellationToken); + + // Assert + var chatDeployment = Assert.Single(deployments, d => d.Name == "gpt-4.1-mini"); + Assert.Equal(AIDeploymentType.Chat, chatDeployment.Type); + + var embeddingDeployment = Assert.Single(deployments, d => d.Name == "text-embedding-3-small"); + Assert.Equal(AIDeploymentType.Embedding, embeddingDeployment.Type); + } + private static DefaultAIDeploymentStore CreateStore( IConfiguration configuration, AIOptions aiOptions, AIDeploymentCatalogOptions catalogOptions = null, + AIProviderConnectionCatalogOptions connectionCatalogOptions = null, List dbEntries = null) { var sources = new List>(); @@ -283,6 +416,7 @@ private static DefaultAIDeploymentStore CreateStore( TimeProvider.System, Options.Create(aiOptions), Options.Create(catalogOptions ?? new AIDeploymentCatalogOptions()), + Options.Create(connectionCatalogOptions ?? new AIProviderConnectionCatalogOptions()), NullLogger.Instance)); return new DefaultAIDeploymentStore(sources);