Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions copilot-cli/.github/instructions/commiting.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfigurationAIDeploymentSource> _logger;

/// <summary>
Expand All @@ -29,18 +30,21 @@ public sealed class ConfigurationAIDeploymentSource : INamedSourceCatalogSource<
/// <param name="timeProvider">The time provider.</param>
/// <param name="aiOptions">The ai options.</param>
/// <param name="catalogOptions">The catalog options.</param>
/// <param name="connectionCatalogOptions">The connection catalog options.</param>
/// <param name="logger">The logger.</param>
public ConfigurationAIDeploymentSource(
IConfiguration configuration,
TimeProvider timeProvider,
IOptions<AIOptions> aiOptions,
IOptions<AIDeploymentCatalogOptions> catalogOptions,
IOptions<AIProviderConnectionCatalogOptions> connectionCatalogOptions,
ILogger<ConfigurationAIDeploymentSource> logger)
{
_configuration = configuration;
_timeProvider = timeProvider;
_aiOptions = aiOptions.Value;
_catalogOptions = catalogOptions.Value;
_connectionCatalogOptions = connectionCatalogOptions.Value;
_logger = logger;
}

Expand Down Expand Up @@ -72,6 +76,7 @@ public ValueTask<IReadOnlyCollection<AIDeployment>> GetEntriesAsync(IReadOnlyCol
try
{
ReadConfiguredDeployments(deployments, names);
ReadConnectionDeployments(deployments, names);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -137,6 +142,127 @@ private void ReadConfiguredDeployments(Dictionary<string, AIDeployment> deployme
}
}

private void ReadConnectionDeployments(Dictionary<string, AIDeployment> deployments, Dictionary<string, string> 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<string, AIDeployment> deployments,
Dictionary<string, string> 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<string, AIDeployment> deployments,
Dictionary<string, string> 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<string, AIDeployment> deployments, Dictionary<string, string> names, string sectionPath)
{
if (_logger.IsEnabled(LogLevel.Debug))
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<AIDeployment> dbEntries = null)
{
var sources = new List<INamedSourceCatalogSource<AIDeployment>>();
Expand All @@ -283,6 +416,7 @@ private static DefaultAIDeploymentStore CreateStore(
TimeProvider.System,
Options.Create(aiOptions),
Options.Create(catalogOptions ?? new AIDeploymentCatalogOptions()),
Options.Create(connectionCatalogOptions ?? new AIProviderConnectionCatalogOptions()),
NullLogger<ConfigurationAIDeploymentSource>.Instance));

return new DefaultAIDeploymentStore(sources);
Expand Down
Loading