diff --git a/.github/workflows/preview_ci.yml b/.github/workflows/preview_ci.yml
index 6cb951ae..a312bb8b 100644
--- a/.github/workflows/preview_ci.yml
+++ b/.github/workflows/preview_ci.yml
@@ -5,6 +5,7 @@ on:
# 4:19 AM UTC every day. A random time to avoid peak times of GitHub Actions.
- cron: '19 4 * * *'
permissions:
+ actions: read
contents: read
packages: write
env:
@@ -19,10 +20,44 @@ jobs:
- name: Check if should publish
id: check-publish
shell: pwsh
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
run: |
- $hasCommitFromLastDay = ![string]::IsNullOrEmpty((git log --oneline --since '24 hours ago'))
- Write-Output "Commits found in the last 24 hours: $hasCommitFromLastDay."
- $shouldPublish = ($hasCommitFromLastDay -and '${{ github.event_name }}' -eq 'schedule') -or ('${{ github.event_name }}' -eq 'workflow_dispatch')
+ $eventName = '${{ github.event_name }}'
+
+ if ($eventName -eq 'workflow_dispatch')
+ {
+ Write-Output 'Manual preview release requested. Publishing unconditionally.'
+ "should-publish=true" >> $Env:GITHUB_OUTPUT
+ exit 0
+ }
+
+ $headers = @{
+ Authorization = "Bearer $Env:GITHUB_TOKEN"
+ Accept = 'application/vnd.github+json'
+ 'X-GitHub-Api-Version' = '2022-11-28'
+ }
+
+ $workflowRunsUrl = 'https://api.github.com/repos/${{ github.repository }}/actions/workflows/preview_ci.yml/runs?status=success&branch=${{ github.ref_name }}&per_page=20'
+ $response = Invoke-RestMethod -Uri $workflowRunsUrl -Headers $headers -Method Get
+ $previousRun = $response.workflow_runs |
+ Where-Object { $_.id -ne [int64]'${{ github.run_id }}' } |
+ Sort-Object created_at -Descending |
+ Select-Object -First 1
+
+ if ($null -eq $previousRun)
+ {
+ Write-Output 'No previous successful preview publish run found. Publishing.'
+ "should-publish=true" >> $Env:GITHUB_OUTPUT
+ exit 0
+ }
+
+ $hasNewCommitSinceLastRelease = $previousRun.head_sha -ne '${{ github.sha }}'
+ Write-Output "Last successful preview publish SHA: $($previousRun.head_sha)"
+ Write-Output "Current SHA: ${{ github.sha }}"
+ Write-Output "New commits since last preview publish: $hasNewCommitSinceLastRelease"
+
+ $shouldPublish = $eventName -eq 'schedule' -and $hasNewCommitSinceLastRelease
"should-publish=$($shouldPublish ? 'true' : 'false')" >> $Env:GITHUB_OUTPUT
- uses: actions/setup-node@v6
if: steps.check-publish.outputs.should-publish == 'true'
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs
index e0a46ca1..72b45e97 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs
@@ -37,7 +37,7 @@ public interface IAIClientFactory
///
/// A representing the asynchronous operation, with the created .
///
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask CreateImageGeneratorAsync(string providerName, string connectionName, string deploymentName = null);
@@ -52,7 +52,7 @@ public interface IAIClientFactory
///
/// A representing the asynchronous operation, with the created .
///
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask CreateSpeechToTextClientAsync(string providerName, string connectionName, string deploymentName = null);
@@ -66,7 +66,7 @@ public interface IAIClientFactory
///
/// A representing the asynchronous operation, with the created .
///
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask CreateSpeechToTextClientAsync(AIDeployment deployment);
@@ -81,7 +81,7 @@ public interface IAIClientFactory
///
/// A representing the asynchronous operation, with the created .
///
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask CreateTextToSpeechClientAsync(string providerName, string connectionName, string deploymentName = null);
@@ -95,7 +95,7 @@ public interface IAIClientFactory
///
/// A representing the asynchronous operation, with the created .
///
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask CreateTextToSpeechClientAsync(AIDeployment deployment);
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs
index 44f6de0c..4ed5ad73 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs
@@ -33,7 +33,7 @@ public interface IAIClientProvider
/// The connection entry containing provider configuration.
/// The optional deployment name to use.
/// A representing the asynchronous operation.
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask GetImageGeneratorAsync(AIProviderConnectionEntry connection, string deploymentName = null);
@@ -45,7 +45,7 @@ public interface IAIClientProvider
/// The connection entry containing provider configuration.
/// The optional deployment name to use.
/// A representing the asynchronous operation.
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask GetSpeechToTextClientAsync(AIProviderConnectionEntry connection, string deploymentName = null);
@@ -57,7 +57,7 @@ public interface IAIClientProvider
/// The connection entry containing provider configuration.
/// The optional deployment name to use.
/// A representing the asynchronous operation.
-
+
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
ValueTask GetTextToSpeechClientAsync(AIProviderConnectionEntry connection, string deploymentName = null);
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs
deleted file mode 100644
index 6d9d6c88..00000000
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using CrestApps.Core.AI.Models;
-using CrestApps.Core.Services;
-
-namespace CrestApps.Core.AI.Deployments;
-
-///
-/// Represents the host-specific persisted AI deployment catalog before any configuration-backed
-/// deployments are merged into read operations.
-///
-public interface IAIDeploymentStore : INamedSourceCatalog
-{
-}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs
index 2e49130e..83a93f94 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs
@@ -6,18 +6,6 @@ namespace CrestApps.Core.AI.Json;
public sealed class AIProviderConnectionConverter : JsonConverter
{
- ///
- /// Maps legacy configuration key names to their current equivalents.
- ///
- private static readonly Dictionary _legacyKeyMappings = new(StringComparer.OrdinalIgnoreCase)
- {
- ["DefaultDeploymentName"] = "ChatDeploymentName",
- ["DefaultChatDeploymentName"] = "ChatDeploymentName",
- ["DefaultUtilityDeploymentName"] = "UtilityDeploymentName",
- ["DefaultEmbeddingDeploymentName"] = "EmbeddingDeploymentName",
- ["DefaultImagesDeploymentName"] = "ImagesDeploymentName",
- };
-
public override AIProviderConnectionEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Deserialize into a dictionary first.
@@ -28,16 +16,6 @@ public override AIProviderConnectionEntry Read(ref Utf8JsonReader reader, Type t
return null;
}
- // Migrate legacy keys to current keys.
- foreach (var (legacyKey, newKey) in _legacyKeyMappings)
- {
- if (dictionary.TryGetValue(legacyKey, out var value) && !dictionary.ContainsKey(newKey))
- {
- dictionary[newKey] = value;
- dictionary.Remove(legacyKey);
- }
- }
-
return new AIProviderConnectionEntry(dictionary);
}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs
index e99f231e..e793d19d 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs
@@ -24,27 +24,27 @@ public override AIProviderConnection Read(ref Utf8JsonReader reader, Type typeTo
?? GetString(node, "ProviderName"),
Name = GetString(node, nameof(AIProviderConnection.Name)),
DisplayText = GetString(node, nameof(AIProviderConnection.DisplayText)),
-#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility
- ChatDeploymentName = GetString(node, nameof(AIProviderConnection.ChatDeploymentName))
- ?? GetString(node, "DefaultDeploymentName"),
- EmbeddingDeploymentName = GetString(node, nameof(AIProviderConnection.EmbeddingDeploymentName))
- ?? GetString(node, "DefaultEmbeddingDeploymentName"),
- ImagesDeploymentName = GetString(node, nameof(AIProviderConnection.ImagesDeploymentName))
- ?? GetString(node, "DefaultImagesDeploymentName"),
- UtilityDeploymentName = GetString(node, nameof(AIProviderConnection.UtilityDeploymentName))
- ?? GetString(node, "DefaultUtilityDeploymentName"),
-#pragma warning restore CS0618
CreatedUtc = GetDateTime(node, nameof(AIProviderConnection.CreatedUtc)),
Author = GetString(node, nameof(AIProviderConnection.Author)),
OwnerId = GetString(node, nameof(AIProviderConnection.OwnerId)),
};
+ var propertyValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
if (node.TryGetPropertyValue(nameof(AIProviderConnection.Properties), out var propertiesNode)
- && propertiesNode is JsonObject properties)
+ && propertiesNode is JsonObject propertiesObject)
{
// Detach from parent before deserializing.
node.Remove(nameof(AIProviderConnection.Properties));
- connection.Properties = properties.Deserialize>() ?? new Dictionary();
+ foreach (var property in propertiesObject.Deserialize>() ?? new Dictionary())
+ {
+ propertyValues[property.Key] = property.Value;
+ }
+ }
+
+ if (propertyValues.Count > 0)
+ {
+ connection.Properties = propertyValues;
}
return connection;
@@ -58,12 +58,6 @@ public override void Write(Utf8JsonWriter writer, AIProviderConnection value, Js
WriteString(writer, nameof(AIProviderConnection.ClientName), value.ClientName);
WriteString(writer, nameof(AIProviderConnection.Name), value.Name);
WriteString(writer, nameof(AIProviderConnection.DisplayText), value.DisplayText);
-#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility
- WriteString(writer, nameof(AIProviderConnection.ChatDeploymentName), value.ChatDeploymentName);
- WriteString(writer, nameof(AIProviderConnection.EmbeddingDeploymentName), value.EmbeddingDeploymentName);
- WriteString(writer, nameof(AIProviderConnection.ImagesDeploymentName), value.ImagesDeploymentName);
- WriteString(writer, nameof(AIProviderConnection.UtilityDeploymentName), value.UtilityDeploymentName);
-#pragma warning restore CS0618
writer.WriteString(nameof(AIProviderConnection.CreatedUtc), value.CreatedUtc);
WriteString(writer, nameof(AIProviderConnection.Author), value.Author);
WriteString(writer, nameof(AIProviderConnection.OwnerId), value.OwnerId);
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs
index 3ff3354d..a2286366 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs
@@ -3,6 +3,7 @@
using CrestApps.Core.Services;
namespace CrestApps.Core.AI.Models;
+
public class AIDeployment : SourceCatalogEntry, INameAwareModel, ISourceAwareModel, ICloneable
{
private string _modelName;
@@ -11,7 +12,11 @@ public class AIDeployment : SourceCatalogEntry, INameAwareModel, ISourceAwareMod
/// This maps to a registered key in AIOptions.Clients.
/// For connection-based deployments, this is typically derived from the connection's ClientName.
///
- public string ClientName { get => Source; set => Source = value; }
+ public string ClientName
+ {
+ get => Source;
+ set => Source = value;
+ }
[Obsolete("Use ClientName instead. Retained for backward compatibility.")]
[JsonIgnore]
@@ -28,21 +33,30 @@ public class AIDeployment : SourceCatalogEntry, INameAwareModel, ISourceAwareMod
/// Gets or sets the provider-facing model or deployment name.
/// Falls back to for backward compatibility with legacy records.
///
- public string ModelName { get => string.IsNullOrWhiteSpace(_modelName) ? Name : _modelName; set => _modelName = value?.Trim(); }
+ public string ModelName
+ {
+ get => string.IsNullOrWhiteSpace(_modelName)
+ ? Name
+ : _modelName;
+ set => _modelName = value?.Trim();
+ }
+
public string ConnectionName { get; set; }
- public string ConnectionNameAlias { get; set; }
+
///
/// Gets or sets the capability types of this deployment (Chat, Utility, Embedding, Image, SpeechToText, TextToSpeech).
/// A deployment can support one or more capabilities.
///
public AIDeploymentType Type { get; set; }
+
///
/// Gets or sets whether this deployment is the default for its selected capability types
/// within its connection.
///
- public bool IsDefault { get; set; }
public DateTime CreatedUtc { get; set; }
+
public string Author { get; set; }
+
public string OwnerId { get; set; }
public bool SupportsType(AIDeploymentType type)
@@ -59,13 +73,11 @@ public AIDeployment Clone()
ModelName = _modelName,
Source = Source,
ConnectionName = ConnectionName,
- ConnectionNameAlias = ConnectionNameAlias,
Type = Type,
- IsDefault = IsDefault,
CreatedUtc = CreatedUtc,
Author = Author,
OwnerId = OwnerId,
Properties = Properties.Clone(),
};
}
-}
\ No newline at end of file
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs
new file mode 100644
index 00000000..fce2cb8e
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs
@@ -0,0 +1,9 @@
+namespace CrestApps.Core.AI.Models;
+
+public sealed class AIDeploymentCatalogOptions
+{
+ public IList DeploymentSections { get; } =
+ [
+ "CrestApps:AI:Deployments",
+ ];
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs
index 70c70dfd..e36062be 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public static class AIDeploymentTypeExtensions
{
private static readonly AIDeploymentType _allSupportedTypes = Enum.GetValues().Where(type => type != AIDeploymentType.None).Aggregate(AIDeploymentType.None, static (current, type) => current | type);
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs
index 6c2e8b33..776dabbc 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs
@@ -12,21 +12,6 @@ public sealed class AIProviderConnection : SourceCatalogEntry, INameAwareModel,
public string DisplayText { get; set; }
- [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")]
- public string ChatDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")]
- public string EmbeddingDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")]
- public string ImagesDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")]
- public string UtilityDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")]
- public string SpeechToTextDeploymentName { get; set; }
-
///
/// Gets or sets the technical name of the AI client implementation associated with this connection.
/// This maps to a registered key in AIOptions.Clients.
@@ -52,34 +37,6 @@ public string ProviderName
public string OwnerId { get; set; }
- public string GetLegacyChatDeploymentName()
- {
-#pragma warning disable CS0618 // Type or member is obsolete
- return ChatDeploymentName;
-#pragma warning restore CS0618 // Type or member is obsolete
- }
-
- public string GetLegacyEmbeddingDeploymentName()
- {
-#pragma warning disable CS0618 // Type or member is obsolete
- return EmbeddingDeploymentName;
-#pragma warning restore CS0618 // Type or member is obsolete
- }
-
- public string GetLegacyImageDeploymentName()
- {
-#pragma warning disable CS0618 // Type or member is obsolete
- return ImagesDeploymentName;
-#pragma warning restore CS0618 // Type or member is obsolete
- }
-
- public string GetLegacyUtilityDeploymentName()
- {
-#pragma warning disable CS0618 // Type or member is obsolete
- return UtilityDeploymentName;
-#pragma warning restore CS0618 // Type or member is obsolete
- }
-
public AIProviderConnection Clone()
{
return new AIProviderConnection
@@ -88,13 +45,6 @@ public AIProviderConnection Clone()
Source = Source,
Name = Name,
DisplayText = DisplayText,
-#pragma warning disable CS0618 // Type or member is obsolete
- ChatDeploymentName = ChatDeploymentName,
- EmbeddingDeploymentName = EmbeddingDeploymentName,
- ImagesDeploymentName = ImagesDeploymentName,
- UtilityDeploymentName = UtilityDeploymentName,
- SpeechToTextDeploymentName = SpeechToTextDeploymentName,
-#pragma warning restore CS0618 // Type or member is obsolete
CreatedUtc = CreatedUtc,
Author = Author,
OwnerId = OwnerId,
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs
new file mode 100644
index 00000000..e991902d
--- /dev/null
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs
@@ -0,0 +1,14 @@
+namespace CrestApps.Core.AI.Models;
+
+public sealed class AIProviderConnectionCatalogOptions
+{
+ public IList ConnectionSections { get; } =
+ [
+ "CrestApps:AI:Connections",
+ ];
+
+ public IList ProviderSections { get; } =
+ [
+ "CrestApps:Providers",
+ ];
+}
diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs
index 1acb1180..f98748ea 100644
--- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs
+++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs
@@ -11,18 +11,6 @@ public class AIProviderOptions
public sealed class AIProvider
{
- [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")]
- public string DefaultChatDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")]
- public string DefaultEmbeddingDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")]
- public string DefaultImagesDeploymentName { get; set; }
-
- [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")]
- public string DefaultUtilityDeploymentName { get; set; }
-
public IDictionary Connections { get; set; }
}
diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs b/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs
index 25293ca4..5d21443a 100644
--- a/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs
+++ b/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace CrestApps.Core.Models;
+
public class ValidationResultDetails
{
private List _errors;
diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
index c50adbbd..42273936 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
@@ -17,3 +17,5 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- includes a reference MVC host and an Aspire host for local composition and testing
- includes a dedicated `CrestApps.Core.Tests` project for framework validation
- publishes a framework-focused documentation site at [core.crestapps.com](https://core.crestapps.com)
+- merges appsettings-backed and UI-managed AI provider connections and deployments through runtime catalogs, so MVC selectors and AI resolution stay current without rebuilding options or restarting the app
+- exposes merged AI connection and deployment views through `INamedSourceCatalog` registrations, adds generic `Add*DocumentCatalog()` helpers for custom catalog registration, keeps deterministic name conflict handling so UI-managed records override conflicting appsettings entries, and standardizes settings so connections and deployments are configured separately
diff --git a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md
index 759dfe7f..5e0231d7 100644
--- a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md
+++ b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md
@@ -67,8 +67,7 @@ At minimum, provide a connection and a deployment through configuration or your
{
"Name": "primary-openai",
"ClientName": "OpenAI",
- "ApiKey": "YOUR_API_KEY",
- "DefaultDeploymentName": "gpt-4.1"
+ "ApiKey": "YOUR_API_KEY"
}
],
"Deployments": [
@@ -76,7 +75,7 @@ At minimum, provide a connection and a deployment through configuration or your
"Name": "gpt-4.1",
"ClientName": "OpenAI",
"Type": "Chat",
- "Model": "gpt-4.1",
+ "ModelName": "gpt-4.1",
"IsDefault": true
}
]
@@ -85,7 +84,7 @@ At minimum, provide a connection and a deployment through configuration or your
}
```
-The MVC sample demonstrates the full runtime options pattern, including configuration-backed connections, UI-managed overrides, and merged deployment catalogs.
+The MVC sample demonstrates the full runtime options pattern, including configuration-backed connections, UI-managed overrides, and merged deployment catalogs. Keep connection credentials under `Connections` and define model/deployment choices under `Deployments`.
## 4. Pick the application model
diff --git a/src/CrestApps.Core.Docs/docs/core/mvc-example.md b/src/CrestApps.Core.Docs/docs/core/mvc-example.md
index 3bbc5f43..1c88159d 100644
--- a/src/CrestApps.Core.Docs/docs/core/mvc-example.md
+++ b/src/CrestApps.Core.Docs/docs/core/mvc-example.md
@@ -144,53 +144,66 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps
```
-The MVC sample configures `AIProviderOptions` inside `AddAISuite(...)` from `CrestApps:AI:Providers`, then layers the UI-managed connection projection through `MvcAIProviderOptionsStore` so admin changes refresh the same options object without querying YesSql from the options pipeline.
+The MVC sample still binds static provider metadata from `CrestApps:AI:Providers`, but mutable AI connections and deployments now come from first-class merged catalogs instead of rebuilding `AIProviderOptions` after admin edits.
`AddCoreAIAzureOpenAI()` also registers the `AzureSpeech` deployment provider used by MVC speech-to-text and text-to-speech selectors, so standalone Azure AI Services deployments from `CrestApps:AI:Deployments` participate in the same merged deployment catalog as UI-managed deployments.
-The shared AI options pipeline now also reads connection definitions from `CrestApps:AI:Connections`, merges them with any UI-managed MVC connections, and exposes the combined set everywhere the runtime resolves provider connections. Each configured connection must provide a `Name` plus `ClientName`, and the MVC AI Deployment editor now uses that merged options source too, so appsettings-defined connections appear alongside admin-created connections when creating or editing deployments.
+The MVC runtime now reads connection definitions from `CrestApps:AI:Connections`, provider-grouped connections under `CrestApps:Providers:{ProviderName}:Connections:{ConnectionName}` or `CrestApps:AI:Providers:{ProviderName}:Connections:{ConnectionName}`, and UI-managed connection records from the store into one merged connection catalog. The deployment catalog layers together UI-managed typed deployments and standalone `CrestApps:AI:Deployments` entries. That means dropdowns, deployment resolution, and connection resolution all see the same unified set without an app restart.
+
+Both merged catalogs also expose configurable section lists through `AIProviderConnectionCatalogOptions` and `AIDeploymentCatalogOptions`, so a host can append additional configuration paths without replacing the MVC/UI store integration. By default, connection discovery reads `CrestApps:AI:Connections`, `CrestApps:Providers`, and `CrestApps:AI:Providers`, while deployment discovery reads `CrestApps:AI:Deployments`.
```json
{
"CrestApps": {
"AI": {
- "Connections": [
- {
- "Name": "primary",
- "ClientName": "OpenAI",
- "ApiKey": "YOUR_API_KEY",
- "DefaultDeploymentName": "gpt-4.1"
- }
- ]
- }
+ "Connections": [
+ {
+ "Name": "WinnerWare",
+ "ClientName": "AzureOpenAI",
+ "Endpoint": "https://winnerwareai.openai.azure.com/",
+ "AuthenticationType": "ApiKey",
+ "ApiKey": "YOUR_API_KEY",
+ "DisplayText": "WinnerWare Azure OpenAI"
+ }
+ ]
+ }
}
}
```
-Provider-grouped connection settings under `CrestApps:Providers:{ProviderName}:Connections:{ConnectionName}` still work too. The framework now keeps those provider-defined connections and the `CrestApps:AI:Connections` array in the same runtime options graph, then merges UI-managed MVC connections on top without duplicating host-specific merge code.
+Provider-grouped connection settings under `CrestApps:Providers:{ProviderName}:Connections:{ConnectionName}` and `CrestApps:AI:Providers:{ProviderName}:Connections:{ConnectionName}` still work too. The merged connection catalog keeps those provider-defined records and the `CrestApps:AI:Connections` array visible alongside UI-managed MVC connections, and the MVC AI Deployment editor reads that catalog directly when it builds the connection dropdown. Connection settings only describe the provider connection itself; deployment names and types belong in `CrestApps:AI:Deployments` or in the UI deployment editor.
```json
{
"CrestApps": {
"AI": {
- "Deployments": [
- {
- "ClientName": "AzureSpeech",
- "Name": "whisper",
- "Type": "SpeechToText",
- "IsDefault": true,
- "Endpoint": "https://eastus.stt.speech.microsoft.com",
- "AuthenticationType": "ApiKey",
- "ApiKey": "YOUR_API_KEY"
- }
- ]
- }
+ "Deployments": [
+ {
+ "ProviderName": "AzureSpeech",
+ "Name": "whisper",
+ "Type": "SpeechToText",
+ "IsDefault": true,
+ "Endpoint": "https://eastus.stt.speech.microsoft.com",
+ "AuthenticationType": "ApiKey",
+ "ApiKey": "YOUR_API_KEY"
+ },
+ {
+ "ProviderName": "AzureSpeech",
+ "Name": "AzureTextToSpeech",
+ "Type": "TextToSpeech",
+ "IsDefault": true,
+ "Endpoint": "https://eastus.tts.speech.microsoft.com",
+ "AuthenticationType": "ApiKey",
+ "ApiKey": "YOUR_API_KEY"
+ }
+ ]
+ }
}
}
```
-When a connection or deployment comes from system configuration, the MVC admin keeps it visible in separate read-only cards below the user-defined records and blocks edit/delete actions. Only records created through the UI remain editable there.
+When a connection or deployment comes from system configuration, the MVC admin keeps it visible in the same listing as user-defined records, marks it read-only, and blocks edit/delete actions. Only records created through the UI remain editable there. Names are also enforced across both sources: MVC rejects duplicate UI names, and if appsettings and the store define the same connection or deployment name, the UI/store record wins and the conflicting configuration record is skipped.
### Section 7 — Elasticsearch Services
diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs
index aaaf04a3..ab4f6047 100644
--- a/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs
@@ -3,6 +3,7 @@
using CrestApps.Core.AI.Models;
namespace CrestApps.Core.AI.A2A.Handlers;
+
internal sealed class A2AAICompletionContextBuilderHandler : IAICompletionContextBuilderHandler
{
public Task BuildingAsync(AICompletionContextBuildingContext context)
diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs
index 8303a59b..58e5c382 100644
--- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs
+++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.A2A.Services;
+
internal sealed class DefaultA2AAgentCardCacheService : IA2AAgentCardCacheService
{
private static readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(15);
diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs
index 4ce1db5d..e0f33fdf 100644
--- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs
+++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs
@@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.A2A.Services;
+
internal sealed class DefaultA2AConnectionAuthService : IA2AConnectionAuthService
{
private const int ExpirationBufferSeconds = 60;
diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs
index b2b68007..41627891 100644
--- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs
@@ -9,6 +9,7 @@
using Microsoft.Extensions.AI;
namespace CrestApps.Core.AI.AzureAIInference.Services;
+
public sealed class AzureAIInferenceClientProvider : AIClientProviderBase
{
public AzureAIInferenceClientProvider(IServiceProvider serviceProvider) : base(serviceProvider)
diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs
index f4e3bd18..60ca255a 100644
--- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs
+++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs
@@ -9,6 +9,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.AzureAIInference.Services;
+
public sealed class AzureAIInferenceCompletionClient : NamedAICompletionClient
{
public AzureAIInferenceCompletionClient(IAIClientFactory aIClientFactory, ILoggerFactory loggerFactory, IDistributedCache distributedCache, IServiceProvider serviceProvider, IOptions providerOptions, IEnumerable handlers, IOptions defaultOptions, ITemplateService aiTemplateService, IAIDeploymentManager deploymentManager) : base(AzureAIInferenceConstants.ImplementationName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, providerOptions.Value, defaultOptions.Value, handlers, aiTemplateService, deploymentManager)
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs
index 1ff1da5e..57ede604 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs
@@ -3,6 +3,7 @@
using CrestApps.Core.Templates.Services;
namespace CrestApps.Core.AI.Chat.Handlers;
+
internal sealed class ChatInteractionCompletionContextBuilderHandler : IAICompletionContextBuilderHandler
{
private readonly ITemplateService _aiTemplateService;
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs
index 4292286b..1ea7e34d 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Chat.Handlers;
+
public sealed class DataSourceChatInteractionSettingsHandler : IChatInteractionSettingsHandler
{
private readonly IServiceProvider _serviceProvider;
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs
index e5979197..6645553f 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs
@@ -2,6 +2,7 @@
using CrestApps.Core.AI.Models;
namespace CrestApps.Core.AI.Chat.Handlers;
+
public sealed class PromptTemplateChatInteractionSettingsHandler : IChatInteractionSettingsHandler
{
public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings)
@@ -22,7 +23,7 @@ private static List GetSelections(JsonElement sett
{
if (!settings.TryGetProperty("promptTemplates", out var promptTemplates) || promptTemplates.ValueKind != JsonValueKind.Array)
{
- return[];
+ return [];
}
var selections = new List();
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
index edcff42e..35cb0f9d 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
@@ -196,7 +196,7 @@ protected virtual Task OnMessageCompletedAsync(IServiceProvider services, ChatMe
///
protected virtual void CollectStreamingReferences(IServiceProvider services, ChatResponseHandlerContext handlerContext, Dictionary references, HashSet contentItemIds)
{
- // No-op. OC overrides to use CitationReferenceCollector.
+ // No-op. OC overrides to use CitationReferenceCollector.
}
// ───────────────── Session title generation ─────────────────
@@ -270,7 +270,7 @@ private static async Task GetAIGeneratedTitleAsync(IServiceProvider serv
return null;
}
- var titleResponse = await completionService.CompleteAsync(chatDeployment, [new(ChatRole.User, userPrompt), ], context);
+ var titleResponse = await completionService.CompleteAsync(chatDeployment, [new(ChatRole.User, userPrompt),], context);
return titleResponse.Messages.Count > 0 ? Truncate(titleResponse.Messages.First().Text, 255) : null;
}
@@ -508,7 +508,7 @@ await ExecuteInScopeAsync(async services =>
}
catch
{
- // Best-effort error reporting.
+ // Best-effort error reporting.
}
}
});
@@ -873,7 +873,7 @@ protected virtual async Task ProcessChatPromptAsync(ChannelWriter();
var handlerResolver = services.GetRequiredService();
var sessionHandlers = services.GetRequiredService>();
- var(chatSession, isNew) = await GetOrCreateSessionAsync(services, sessionId, profile, prompt);
+ var (chatSession, isNew) = await GetOrCreateSessionAsync(services, sessionId, profile, prompt);
await Groups.AddToGroupAsync(Context.ConnectionId, GetSessionGroupName(chatSession.SessionId), cancellationToken);
var utcNow = GetUtcNow();
if (chatSession.Status == ChatSessionStatus.Closed)
@@ -1116,7 +1116,7 @@ protected virtual object CreateSessionPayload(AIChatSession chatSession, AIProfi
///
/// Synthesizes the given text as speech and streams audio chunks to the caller.
///
-
+
#pragma warning disable MEAI001
protected async Task StreamSpeechAsync(ITextToSpeechClient textToSpeechClient, string identifier, string text, string voiceName, CancellationToken cancellationToken)
{
@@ -1214,9 +1214,9 @@ private async Task RunConversationLoopAsync(AIProfile profile, string sessionId,
}
}
}
- catch (OperationCanceledException)when (errorCts.IsCancellationRequested)
+ catch (OperationCanceledException) when (errorCts.IsCancellationRequested)
{
- // Transcription error or connection aborted.
+ // Transcription error or connection aborted.
}
await pipe.Writer.CompleteAsync();
@@ -1272,7 +1272,7 @@ private async Task TranscribeConversationAsync(System.IO.Pipelines.PipeReader pi
{
effectiveSessionId = await currentResponseTask;
}
- catch (OperationCanceledException)when (!cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
Logger.LogDebug("AI response was interrupted by new user speech.");
}
@@ -1310,9 +1310,9 @@ private async Task TranscribeConversationAsync(System.IO.Pipelines.PipeReader pi
{
effectiveSessionId = await currentResponseTask;
}
- catch (OperationCanceledException)when (!cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
- // Interrupted.
+ // Interrupted.
}
currentResponseCts?.Dispose();
@@ -1328,9 +1328,9 @@ private async Task TranscribeConversationAsync(System.IO.Pipelines.PipeReader pi
{
await ProcessConversationPromptAsync(profile, effectiveSessionId, remainingText, textToSpeechClient, voiceName, services, cancellationToken);
}
- catch (OperationCanceledException)when (!cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
- // Interrupted.
+ // Interrupted.
}
}
}
@@ -1419,7 +1419,7 @@ private async Task ProcessConversationPromptAsync(AIProfile profile, str
}
catch
{
- // Best-effort — the client may have disconnected.
+ // Best-effort — the client may have disconnected.
}
}
}
@@ -1450,9 +1450,9 @@ private async Task StreamTranscriptionAsync(ISpeechToTextClient speechToTextClie
}
}
}
- catch (OperationCanceledException)when (errorCts.IsCancellationRequested)
+ catch (OperationCanceledException) when (errorCts.IsCancellationRequested)
{
- // Transcription failed or connection aborted.
+ // Transcription failed or connection aborted.
}
await pipe.Writer.CompleteAsync();
diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs
index 3d35de7b..5e6dc1b1 100644
--- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs
+++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs
@@ -153,7 +153,7 @@ public async IAsyncEnumerable ExecuteStreamingAsync(Orchestr
_logger.LogError(ex, "CopilotOrchestrator: CLI process error. The Copilot CLI may have crashed or failed to start.");
responseText = "The Copilot service encountered an error and could not process your request. Please try again.";
}
- catch (Exception ex)when (ex is not OperationCanceledException)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "CopilotOrchestrator: Unexpected error during Copilot session.");
responseText = "An unexpected error occurred while communicating with Copilot. Please try again.";
diff --git a/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs
index b1ce43dd..ccb01ab8 100644
--- a/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Localization;
namespace CrestApps.Core.AI.Ftp;
+
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCoreAIFtpMcpResources(this IServiceCollection services, Action configure = null)
diff --git a/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs
index 4de7b78d..45e68a70 100644
--- a/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs
@@ -1,6 +1,6 @@
using CrestApps.Core.AI.Markdown.Services;
-using CrestApps.Core.Builders;
using CrestApps.Core.AI.Services;
+using CrestApps.Core.Builders;
using Microsoft.Extensions.DependencyInjection;
namespace CrestApps.Core.AI.Markdown;
diff --git a/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs b/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs
index 0aae6765..bc176633 100644
--- a/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs
+++ b/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs
@@ -31,7 +31,7 @@ public static async Task> NormalizeAndChunkAsync(string text, Cance
{
if (string.IsNullOrWhiteSpace(text))
{
- return[];
+ return [];
}
var document = await ParseDocumentAsync(StripHtml(text), cancellationToken);
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs
index 4d335eef..b8af7280 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs
@@ -3,6 +3,7 @@
using CrestApps.Core.AI.Models;
namespace CrestApps.Core.AI.Mcp.Handlers;
+
internal sealed class McpAICompletionContextBuilderHandler : IAICompletionContextBuilderHandler
{
public Task BuildingAsync(AICompletionContextBuildingContext context)
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs
index d10d1b19..4d1e9a32 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Localization;
namespace CrestApps.Core.AI.Mcp.Models;
+
public sealed class McpClientAIOptions
{
private readonly Dictionary _transportTypes = new(StringComparer.OrdinalIgnoreCase);
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs
index dcb602f3..609e3e11 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Localization;
namespace CrestApps.Core.AI.Mcp.Models;
+
public sealed class McpOptions
{
private readonly Dictionary _resourceTypes = new(StringComparer.OrdinalIgnoreCase);
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs
index 676ef0e2..91519bd0 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs
@@ -6,6 +6,7 @@
using ModelContextProtocol.Server;
namespace CrestApps.Core.AI.Mcp.Services;
+
public sealed class DefaultMcpServerResourceService : IMcpServerResourceService
{
private readonly ISourceCatalog _catalog;
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs
index 7f9fee07..d44af760 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Mcp.Services;
+
public sealed class DefaultOAuth2TokenService : IOAuth2TokenService
{
private const int ExpirationBufferSeconds = 60;
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs
index 29c2d2fe..32208561 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs
@@ -5,6 +5,7 @@
using ModelContextProtocol.Client;
namespace CrestApps.Core.AI.Mcp.Services;
+
public sealed class SseClientTransportProvider : IMcpClientTransportProvider
{
private readonly IDataProtectionProvider _dataProtectionProvider;
diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs
index b08d16cb..e1f4e777 100644
--- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs
@@ -2,6 +2,7 @@
using ModelContextProtocol.Client;
namespace CrestApps.Core.AI.Mcp.Services;
+
public sealed class StdioClientTransportProvider : IMcpClientTransportProvider
{
public bool CanHandle(McpConnection connection)
diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs
index ed18c419..45187c72 100644
--- a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs
@@ -5,6 +5,7 @@
using OllamaSharp;
namespace CrestApps.Core.AI.Ollama.Services;
+
public sealed class OllamaAIClientProvider : AIClientProviderBase
{
public OllamaAIClientProvider(IServiceProvider serviceProvider) : base(serviceProvider)
diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs
index b302ed85..42e39684 100644
--- a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs
+++ b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs
@@ -9,6 +9,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.Ollama.Services;
+
public sealed class OllamaCompletionClient : NamedAICompletionClient
{
public OllamaCompletionClient(IAIClientFactory aIClientFactory, ILoggerFactory loggerFactory, IDistributedCache distributedCache, IServiceProvider serviceProvider, IOptions providerOptions, IEnumerable handlers, IOptions defaultOptions, ITemplateService aiTemplateService, IAIDeploymentManager deploymentManager) : base(OllamaConstants.ImplementationName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, providerOptions.Value, defaultOptions.Value, handlers, aiTemplateService, deploymentManager)
diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs
index d6a89c07..8752557d 100644
--- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs
@@ -11,6 +11,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.OpenAI.Azure.Services;
+
public sealed class AzureOpenAIClientProvider : AIClientProviderBase
{
private readonly ILoggerFactory _loggerFactory;
@@ -69,7 +70,8 @@ private AzureOpenAIClient GetClient(AIProviderConnectionEntry connection, Uri en
AzureAuthenticationType.ApiKey => new AzureOpenAIClient(endpoint, new ApiKeyCredential(connection.GetApiKey()), options),
AzureAuthenticationType.ManagedIdentity => new AzureOpenAIClient(endpoint, new ManagedIdentityCredential(string.IsNullOrEmpty(identityId) ? ManagedIdentityId.SystemAssigned : ManagedIdentityId.FromUserAssignedClientId(identityId)), options),
AzureAuthenticationType.Default => new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), options),
- _ => throw new NotSupportedException("The provided authentication type is not supported.")};
+ _ => throw new NotSupportedException("The provided authentication type is not supported.")
+ };
return azureClient;
}
}
\ No newline at end of file
diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs
index 7a3d0b3d..023d63fc 100644
--- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs
+++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs
@@ -20,6 +20,7 @@
using OpenAI.Chat;
namespace CrestApps.Core.AI.OpenAI.Azure.Services;
+
public sealed class AzureOpenAICompletionClient : AICompletionServiceBase, IAICompletionClient
{
private readonly IServiceProvider _serviceProvider;
@@ -56,7 +57,7 @@ public string Name
var connectionName = context.ConnectionName;
// Use the deployment resolver with fallback to legacy dictionary-based resolution.
- var(deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName);
+ var (deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName);
connectionName = resolvedConnectionName;
if (string.IsNullOrEmpty(connectionName))
{
@@ -170,7 +171,7 @@ public string Name
var connectionName = context.ConnectionName;
// Use the deployment resolver with fallback to legacy dictionary-based resolution.
- var(deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName);
+ var (deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName);
connectionName = resolvedConnectionName;
if (string.IsNullOrEmpty(connectionName) || !provider.Connections.TryGetValue(connectionName, out var connection))
{
@@ -403,7 +404,8 @@ private AzureOpenAIClient GetChatClient(AIProviderConnectionEntry connection)
AzureAuthenticationType.ApiKey => new AzureOpenAIClient(endpoint, new ApiKeyCredential(connection.GetApiKey()), _clientOptions),
AzureAuthenticationType.ManagedIdentity => new AzureOpenAIClient(endpoint, new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned), _clientOptions),
AzureAuthenticationType.Default => new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), _clientOptions),
- _ => throw new NotSupportedException("The specified authentication type is not supported.")};
+ _ => throw new NotSupportedException("The specified authentication type is not supported.")
+ };
return azureClient;
}
@@ -456,7 +458,7 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn
{
if (context.DisableTools)
{
- return[];
+ return [];
}
// Use the same handler pipeline as NamedAICompletionClient to resolve tools.
@@ -476,7 +478,7 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn
if (chatOptions.Tools is null || chatOptions.Tools.Count == 0)
{
- return[];
+ return [];
}
return chatOptions.Tools.OfType().ToList();
diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs
index 3b01decd..6a0efa0c 100644
--- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs
@@ -46,7 +46,7 @@ public ValueTask GetImageGeneratorAsync(AIProviderConnectionEnt
public ValueTask GetSpeechToTextClientAsync(AIProviderConnectionEntry connection, string deploymentName = null)
{
- var(endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection);
+ var (endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection);
var logger = _loggerFactory.CreateLogger();
var client = new AzureSpeechServiceSpeechToTextClient(endpoint, authType, apiKey, identityId, _timeProvider, logger);
return ValueTask.FromResult(client);
@@ -57,7 +57,7 @@ public ValueTask GetSpeechToTextClientAsync(AIProviderConne
public ValueTask GetTextToSpeechClientAsync(AIProviderConnectionEntry connection, string deploymentName = null)
{
- var(endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection);
+ var (endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection);
var logger = _loggerFactory.CreateLogger();
var client = new AzureSpeechServiceTextToSpeechClient(endpoint, authType, apiKey, identityId, _timeProvider, logger);
return ValueTask.FromResult(client);
@@ -67,7 +67,7 @@ public ValueTask GetTextToSpeechClientAsync(AIProviderConne
public async Task GetSpeechVoicesAsync(AIProviderConnectionEntry connection, string deploymentName = null)
{
- var(endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection);
+ var (endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection);
var logger = _loggerFactory.CreateLogger();
using var client = new AzureSpeechServiceTextToSpeechClient(endpoint, authType, apiKey, identityId, _timeProvider, logger);
return await client.GetVoicesAsync();
diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs
index 2c5b7b06..d1664dcf 100644
--- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs
+++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs
@@ -28,7 +28,7 @@ public sealed class AzureSpeechServiceSpeechToTextClient : ISpeechToTextClient
#pragma warning restore MEAI001
{
private const string CognitiveServicesScope = "https://cognitiveservices.azure.com/.default";
- private static readonly string[] _regionSuffixes = [".api.cognitive.microsoft.com", ".tts.speech.microsoft.com", ".stt.speech.microsoft.com", ];
+ private static readonly string[] _regionSuffixes = [".api.cognitive.microsoft.com", ".tts.speech.microsoft.com", ".stt.speech.microsoft.com",];
private readonly Uri _endpoint;
private readonly AzureAuthenticationType _authType;
private readonly string _apiKey;
@@ -254,7 +254,7 @@ public async IAsyncEnumerable GetStreamingTextAsync(
}
catch (OperationCanceledException)
{
- // Expected when the connection is closed or recognition is stopped.
+ // Expected when the connection is closed or recognition is stopped.
}
try
@@ -303,7 +303,7 @@ private async Task PushAudioToStreamAsync(string traceId, Stopwatch sw, Stream a
_logger.LogTrace("[STT:{TraceId}] +{Elapsed}ms PushAudioToStream DONE. Chunks={ChunkCount}, TotalBytes={TotalBytes}", traceId, sw.ElapsedMilliseconds, chunkCount, totalBytes);
}
}
- catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -479,7 +479,7 @@ private static string TryExtractRegion(Uri endpoint)
/// when the value is missing or unrecognized,
/// which lets the SDK auto-detect the container format.
///
-
+
#pragma warning disable MEAI001
private AudioStreamContainerFormat ResolveContainerFormat(SpeechToTextOptions options)
#pragma warning restore MEAI001
@@ -545,7 +545,7 @@ private static SpeechRecognizer CreateRecognizer(SpeechConfig speechConfig, Audi
{
return new SpeechRecognizer(speechConfig, audioConfig);
}
- catch (ApplicationException ex)when (IsGStreamerError(ex))
+ catch (ApplicationException ex) when (IsGStreamerError(ex))
{
throw new InvalidOperationException("Azure Speech SDK requires GStreamer to decode compressed audio. " + "Install GStreamer from https://gstreamer.freedesktop.org/download/ and ensure " + "the binaries are in the system PATH. " + "See: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-use-codec-compressed-audio-input-streams", ex);
}
diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs
index 73b8a016..85e1427a 100644
--- a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs
+++ b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs
@@ -6,6 +6,7 @@
using OpenAI;
namespace CrestApps.Core.AI.OpenAI.Services;
+
public sealed class OpenAIClientProvider : AIClientProviderBase
{
public OpenAIClientProvider(IServiceProvider serviceProvider) : base(serviceProvider)
diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs
index b1d5bcb4..b8d75271 100644
--- a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs
+++ b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs
@@ -9,6 +9,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.OpenAI.Services;
+
public sealed class OpenAICompletionClient : NamedAICompletionClient
{
public OpenAICompletionClient(IAIClientFactory aIClientFactory, ILoggerFactory loggerFactory, IDistributedCache distributedCache, IServiceProvider serviceProvider, IOptions providerOptions, IEnumerable handlers, IOptions defaultOptions, ITemplateService aiTemplateService, IAIDeploymentManager deploymentManager) : base(OpenAIConstants.ImplementationName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, providerOptions.Value, defaultOptions.Value, handlers, aiTemplateService, deploymentManager)
diff --git a/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs
index 93b246ba..80ac44d3 100644
--- a/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Localization;
namespace CrestApps.Core.AI.Sftp;
+
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCoreAISftpMcpResources(this IServiceCollection services, Action configure = null)
diff --git a/src/Primitives/CrestApps.Core.AI/AIOptions.cs b/src/Primitives/CrestApps.Core.AI/AIOptions.cs
index 55a06d99..5da3535d 100644
--- a/src/Primitives/CrestApps.Core.AI/AIOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/AIOptions.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.Localization;
namespace CrestApps.Core.AI;
+
public sealed class AIOptions
{
private readonly Dictionary _clients = new(StringComparer.OrdinalIgnoreCase);
diff --git a/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs b/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs
index 57cd4e82..b16f92d5 100644
--- a/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.AI;
namespace CrestApps.Core.AI.Extensions;
+
public static class AIFunctionArgumentsExtensions
{
public static bool TryGetFirst(this AIFunctionArguments arguments, string key, out object value)
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs
index 77c6b1f2..4866ad72 100644
--- a/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs
@@ -2,6 +2,7 @@
using CrestApps.Core.AI.Models;
namespace CrestApps.Core.AI.Handlers;
+
public abstract class AICompletionHandlerBase : IAICompletionHandler
{
public virtual Task ReceivedMessageAsync(ReceivedMessageContext context)
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs
index e309595e..531cfeca 100644
--- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.Handlers;
+
internal static class AIMemoryOrchestrationContextHelper
{
public static string GetAuthenticatedUserId(IHttpContextAccessor httpContextAccessor)
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs
index 584cc966..7fc5d5fc 100644
--- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs
@@ -10,6 +10,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.Handlers;
+
internal sealed class AIMemoryOrchestrationHandler : IOrchestrationContextBuilderHandler
{
private readonly ITemplateService _templateService;
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs
index 1e7055ec..17e3bf5b 100644
--- a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Handlers;
+
internal sealed class AgentOrchestrationContextBuilderHandler : IOrchestrationContextBuilderHandler
{
private readonly IAIProfileManager _profileManager;
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs
index 4c4baa0d..dec1f6c8 100644
--- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs
@@ -3,6 +3,7 @@
using CrestApps.Core.AI.Orchestration;
namespace CrestApps.Core.AI.Handlers;
+
internal sealed class DataSourceAICompletionContextBuilderHandler : IAICompletionContextBuilderHandler
{
public Task BuildingAsync(AICompletionContextBuildingContext context)
diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs
index 60a6c7bb..52642d64 100644
--- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.Handlers;
+
internal sealed class DataSourceOrchestrationHandler : IOrchestrationContextBuilderHandler
{
private readonly AIToolDefinitionOptions _toolDefinitions;
diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs
index d82cd87a..fe154aa4 100644
--- a/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Indexing;
+
public sealed class AIDocumentSearchIndexProfileHandler : EmbeddingSearchIndexProfileHandlerBase
{
public AIDocumentSearchIndexProfileHandler(ICatalog deploymentCatalog, IAIClientFactory aiClientFactory, ILogger logger) : base(IndexProfileTypes.AIDocuments, deploymentCatalog, aiClientFactory, logger)
@@ -15,7 +16,7 @@ public AIDocumentSearchIndexProfileHandler(ICatalog deploymentCata
protected override IReadOnlyCollection BuildFields(int vectorDimensions)
{
- return[new SearchIndexField
+ return [new SearchIndexField
{
Name = DocumentIndexConstants.ColumnNames.ChunkId,
FieldType = SearchFieldType.Keyword,
diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs
index ff30e52d..e75582de 100644
--- a/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Indexing;
+
public sealed class AIMemorySearchIndexProfileHandler : EmbeddingSearchIndexProfileHandlerBase
{
private const string _memoryIdFieldName = "memoryId";
@@ -21,7 +22,7 @@ public AIMemorySearchIndexProfileHandler(ICatalog deploymentCatalo
protected override IReadOnlyCollection BuildFields(int vectorDimensions)
{
- return[new SearchIndexField
+ return [new SearchIndexField
{
Name = _memoryIdFieldName,
FieldType = SearchFieldType.Keyword,
diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs
index debca9cb..17dba089 100644
--- a/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs
@@ -7,6 +7,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Indexing;
+
public sealed class DataSourceSearchIndexProfileHandler : EmbeddingSearchIndexProfileHandlerBase
{
public DataSourceSearchIndexProfileHandler(ICatalog deploymentCatalog, IAIClientFactory aiClientFactory, ILogger logger) : base(IndexProfileTypes.DataSource, deploymentCatalog, aiClientFactory, logger)
@@ -15,7 +16,7 @@ public DataSourceSearchIndexProfileHandler(ICatalog deploymentCata
protected override IReadOnlyCollection BuildFields(int vectorDimensions)
{
- return[new SearchIndexField
+ return [new SearchIndexField
{
Name = DataSourceConstants.ColumnNames.ChunkId,
FieldType = SearchFieldType.Keyword,
diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs
index 17d55ab6..7b4e7dad 100644
--- a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs
+++ b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Indexing;
+
public abstract class EmbeddingSearchIndexProfileHandlerBase : IndexProfileHandlerBase
{
private readonly string _type;
diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs
index fae8473b..99d29644 100644
--- a/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs
+++ b/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs
@@ -4,6 +4,7 @@
using CrestApps.Core.Models;
namespace CrestApps.Core.AI.Indexing;
+
public abstract class IndexProfileHandlerBase : CatalogEntryHandlerBase, IIndexProfileHandler
{
public virtual ValueTask ValidateAsync(SearchIndexProfile indexProfile, ValidationResultDetails result, CancellationToken cancellationToken = default)
diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs
index 0a8bb490..bfcf4b12 100644
--- a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs
+++ b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Indexing;
+
public sealed class SearchIndexProfileManager : CatalogManager, ISearchIndexProfileManager
{
private readonly ISearchIndexProfileStore _store;
diff --git a/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs
index 770a3ee6..f489bf21 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs
@@ -19,9 +19,9 @@ public AIDataSourceOptions Clone()
DefaultStrictness = DefaultStrictness,
DefaultTopNDocuments = DefaultTopNDocuments,
};
- foreach (var(providerName, providerMappings)in _fieldMappings)
+ foreach (var (providerName, providerMappings) in _fieldMappings)
{
- foreach (var(indexProfileType, mapping)in providerMappings)
+ foreach (var (indexProfileType, mapping) in providerMappings)
{
clone.AddFieldMapping(providerName, indexProfileType, target =>
{
diff --git a/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs
index 3c586c1c..d81620b4 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public sealed class AIMemoryOptions
{
public string IndexProfileName { get; set; }
diff --git a/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs b/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs
index dcd4d92c..fa361321 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public static class ChatDocumentsOptionsExtensions
{
public static string GetAllowedFileExtensionsAcceptValue(this ChatDocumentsOptions options)
diff --git a/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs
index b912dc34..a5c41287 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public sealed class ChatInteractionMemoryOptions
{
public bool EnableUserMemory { get; set; } = true;
diff --git a/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs b/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs
index 0462873e..8a25f2d4 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public sealed class ExtractorExtension : IEquatable, IEquatable
{
public string Extension { get; }
@@ -53,7 +54,7 @@ public override int GetHashCode()
return !Equals(left, right);
}
- public static implicit operator string (ExtractorExtension ext)
+ public static implicit operator string(ExtractorExtension ext)
{
return ext.Extension;
}
diff --git a/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs
index 5b4d4252..ac92c7b1 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public sealed class GeneralAIOptions
{
public bool EnableAIUsageTracking { get; set; }
diff --git a/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs
index 93e1f7e3..e0154f8f 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs
@@ -1,4 +1,5 @@
namespace CrestApps.Core.AI.Models;
+
public sealed class InteractionDocumentOptions
{
///
diff --git a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs
index bfd24999..5e129292 100644
--- a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs
@@ -2,6 +2,7 @@
using System.Text.Json.Nodes;
namespace CrestApps.Core.AI.Models;
+
public static class MemoryMetadataExtensions
{
public const string LegacyAIProfileSettingsKey = "AIProfileMemorySettings";
diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs
index b89a2dce..ee553e2f 100644
--- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs
@@ -128,6 +128,8 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi
services
.AddCoreAITemplating()
.AddCoreServices()
+ .AddOptions().Services
+ .AddOptions().Services
.AddScoped()
.AddScoped();
@@ -250,6 +252,7 @@ public static IServiceCollection AddCoreAIDataSourceRag(this IServiceCollection
public static IServiceCollection AddCoreAIMemory(this IServiceCollection services)
{
services.TryAddSingleton(TimeProvider.System);
+ services.AddCatalogManagers();
services.AddOptions();
services.AddOptions();
services.AddOptions();
@@ -257,7 +260,6 @@ public static IServiceCollection AddCoreAIMemory(this IServiceCollection service
services.TryAddScoped();
services.TryAddScoped();
services.TryAdd(ServiceDescriptor.Scoped>(sp => sp.GetRequiredService()));
- services.TryAddScoped, CatalogManager>();
services.TryAddEnumerable(ServiceDescriptor.Scoped());
services.TryAddEnumerable(ServiceDescriptor.Scoped());
diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs
index e0cff03b..f99b0b0b 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.AI;
namespace CrestApps.Core.AI.Services;
+
public abstract class AIClientProviderBase : IAIClientProvider
{
private readonly IServiceProvider _serviceProvider;
diff --git a/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs
index e0d25dcc..80f1f360 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs
@@ -42,13 +42,11 @@ protected virtual string GetDefaultDeploymentName(AIProvider provider, string co
}
}
-#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility
- return provider.DefaultChatDeploymentName;
-#pragma warning restore CS0618
+ return null;
}
///
/// Resolves a deployment name and connection name using the
- /// with fallback to the legacy dictionary-based resolution.
+ /// with fallback to legacy connection entry values when they are still present.
///
protected virtual async ValueTask<(string DeploymentName, string ConnectionName)> ResolveDeploymentAsync(
AIDeploymentType type,
diff --git a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs
index d6c4e127..cbce8bb4 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs
@@ -2,6 +2,7 @@
using CrestApps.Core.AI.Models;
namespace CrestApps.Core.AI.Services;
+
public static class AICompletionUsageRecordFactory
{
public static AICompletionUsageRecord Create(AICompletionContext completionContext, string providerName, string clientName, string connectionName, string deploymentName, string modelName, string responseId, long inputTokenCount, long outputTokenCount, long totalTokenCount, double responseLatencyMs, bool isStreaming)
diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs b/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs
index ceb60fda..d9d1c2bf 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs
@@ -2,6 +2,7 @@
using System.Text;
namespace CrestApps.Core.AI.Services;
+
public static class AIConfigurationRecordIds
{
private const string _connectionPrefix = "cfgc";
diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs
new file mode 100644
index 00000000..10f886db
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs
@@ -0,0 +1,48 @@
+using System.Text.Json.Nodes;
+using CrestApps.Core.AI.Models;
+
+namespace CrestApps.Core.AI.Services;
+
+internal static class AIProviderConnectionEntryFactory
+{
+ public static AIProviderConnectionEntry Create(AIProviderConnection connection)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (connection.Properties != null)
+ {
+ foreach (var property in connection.Properties)
+ {
+ values[property.Key] = property.Value is JsonNode jsonNode
+ ? ConvertJsonNode(jsonNode)
+ : property.Value;
+ }
+ }
+
+ values["DisplayText"] = string.IsNullOrWhiteSpace(connection.DisplayText)
+ ? connection.Name
+ : connection.DisplayText;
+
+ return new AIProviderConnectionEntry(values);
+ }
+
+ private static object ConvertJsonNode(JsonNode node)
+ {
+ return node switch
+ {
+ JsonObject jsonObject => jsonObject.ToDictionary(
+ property => property.Key,
+ property => ConvertJsonNode(property.Value),
+ StringComparer.OrdinalIgnoreCase),
+ JsonArray jsonArray => jsonArray.Select(ConvertJsonNode).ToList(),
+ JsonValue jsonValue when jsonValue.TryGetValue(out var s) => s,
+ JsonValue jsonValue when jsonValue.TryGetValue(out var b) => b,
+ JsonValue jsonValue when jsonValue.TryGetValue(out var i) => i,
+ JsonValue jsonValue when jsonValue.TryGetValue(out var l) => l,
+ JsonValue jsonValue when jsonValue.TryGetValue(out var d) => d,
+ _ => node?.ToString(),
+ };
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs
index 22ad3328..66877da2 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs
@@ -58,7 +58,6 @@ public static bool MergeConnection(
var normalizedConnection = NormalizeConnection(connectionName, connection);
provider.Connections[connectionName] = normalizedConnection;
- ApplyMissingDefaults(provider, normalizedConnection);
return true;
}
@@ -67,21 +66,17 @@ private static AIProviderConnectionEntry NormalizeConnection(string connectionNa
{
var values = new Dictionary(connection, StringComparer.OrdinalIgnoreCase);
- if (string.IsNullOrWhiteSpace(values.GetStringValue("ConnectionNameAlias", false)))
+ var displayText = values.GetStringValue("DisplayText", false);
+
+ if (string.IsNullOrWhiteSpace(displayText))
+ {
+ values["DisplayText"] = connectionName;
+ }
+ else
{
- values["ConnectionNameAlias"] = connectionName;
+ values["DisplayText"] = displayText;
}
return new AIProviderConnectionEntry(values);
}
-
- private static void ApplyMissingDefaults(AIProvider provider, AIProviderConnectionEntry connection)
- {
-#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility
- provider.DefaultChatDeploymentName ??= connection.GetChatDeploymentOrDefaultName(false);
- provider.DefaultEmbeddingDeploymentName ??= connection.GetEmbeddingDeploymentOrDefaultName(false);
- provider.DefaultImagesDeploymentName ??= connection.GetImagesDeploymentOrDefaultName(false);
- provider.DefaultUtilityDeploymentName ??= connection.GetUtilityDeploymentOrDefaultName(false);
-#pragma warning restore CS0618
- }
}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs
index a3da91eb..eebef1fc 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs
@@ -1,9 +1,10 @@
using System.Text.Json;
using System.Text.Json.Nodes;
-using CrestApps.Core.AI.Deployments;
using CrestApps.Core.AI.Models;
using CrestApps.Core.Models;
+using CrestApps.Core.Services;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -12,20 +13,27 @@ namespace CrestApps.Core.AI.Services;
/// Decorates a persisted AI deployment store with configuration-backed deployments from appsettings.json.
/// Read operations return the merged result while write operations continue to target the persisted store only.
///
-public sealed class ConfigurationAIDeploymentCatalog : IAIDeploymentStore
+public sealed class ConfigurationAIDeploymentCatalog : INamedSourceCatalog
{
- private readonly IAIDeploymentStore _inner;
+ public const string PersistedCatalogKey = "PersistedCatalog";
+
+ private readonly INamedSourceCatalog _inner;
private readonly IConfiguration _configuration;
- private readonly AIProviderOptions _providerOptions;
private readonly AIOptions _aiOptions;
+ private readonly AIDeploymentCatalogOptions _catalogOptions;
private readonly ILogger _logger;
- private IReadOnlyCollection _configDeployments;
- public ConfigurationAIDeploymentCatalog(IAIDeploymentStore inner, IConfiguration configuration, IOptions providerOptions, IOptions aiOptions, ILogger logger)
+
+ public ConfigurationAIDeploymentCatalog(
+ [FromKeyedServices(PersistedCatalogKey)] INamedSourceCatalog inner,
+ IConfiguration configuration,
+ IOptions aiOptions,
+ IOptions catalogOptions,
+ ILogger logger)
{
_inner = inner;
_configuration = configuration;
- _providerOptions = providerOptions.Value;
_aiOptions = aiOptions.Value;
+ _catalogOptions = catalogOptions.Value;
_logger = logger;
}
@@ -37,13 +45,15 @@ public async ValueTask FindByIdAsync(string id)
return result;
}
- return FindConfigDeployment(deployment => string.Equals(deployment.ItemId, id, StringComparison.OrdinalIgnoreCase));
+ return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync()))
+ .FirstOrDefault(deployment => string.Equals(deployment.ItemId, id, StringComparison.OrdinalIgnoreCase))
+ ?.Clone();
}
public async ValueTask> GetAllAsync()
{
var dbRecords = await _inner.GetAllAsync();
- var configRecords = GetConfigDeployments();
+ var configRecords = await GetConfigDeploymentsAsync(dbRecords);
if (configRecords.Count == 0)
{
return dbRecords;
@@ -63,7 +73,7 @@ public async ValueTask> GetAsync(IEnumerable missingIds.Contains(deployment.ItemId)).ToList();
+ var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => missingIds.Contains(deployment.ItemId)).ToList();
if (configMatches.Count == 0)
{
return dbRecords;
@@ -75,7 +85,7 @@ public async ValueTask> GetAsync(IEnumerable> PageAsync(int page, int pageSize, TQuery context)
where TQuery : QueryContext
{
- var configRecords = GetConfigDeployments();
+ var configRecords = await GetConfigDeploymentsAsync(await _inner.GetAllAsync());
if (configRecords.Count == 0)
{
return await _inner.PageAsync(page, pageSize, context);
@@ -99,13 +109,15 @@ public async ValueTask FindByNameAsync(string name)
return result;
}
- return FindConfigDeployment(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase));
+ return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync()))
+ .FirstOrDefault(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase))
+ ?.Clone();
}
public async ValueTask> GetAsync(string source)
{
var dbRecords = await _inner.GetAsync(source);
- var configMatches = GetConfigDeployments().Where(deployment => string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)).ToList();
+ var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)).ToList();
if (configMatches.Count == 0)
{
return dbRecords;
@@ -122,116 +134,65 @@ public async ValueTask GetAsync(string name, string source)
return result;
}
- return FindConfigDeployment(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase) && string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase));
+ return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync()))
+ .FirstOrDefault(deployment =>
+ string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase))
+ ?.Clone();
}
public ValueTask DeleteAsync(AIDeployment entry) => _inner.DeleteAsync(entry);
public ValueTask CreateAsync(AIDeployment entry) => _inner.CreateAsync(entry);
public ValueTask UpdateAsync(AIDeployment entry) => _inner.UpdateAsync(entry);
- private AIDeployment FindConfigDeployment(Func predicate)
- {
- return GetConfigDeployments().FirstOrDefault(predicate)?.Clone();
- }
- private IReadOnlyCollection GetConfigDeployments()
+ private async Task> GetConfigDeploymentsAsync(IReadOnlyCollection storedDeployments)
{
- if (_configDeployments != null)
- {
- return _configDeployments;
- }
+ var deployments = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var names = storedDeployments
+ .Where(static deployment => !string.IsNullOrWhiteSpace(deployment.Name))
+ .ToDictionary(static deployment => deployment.Name, static deployment => deployment.ItemId, StringComparer.OrdinalIgnoreCase);
- var deployments = new List();
try
{
- ReadConnectionDeployments(deployments);
- ReadStandaloneDeployments(deployments);
+ ReadStandaloneDeployments(deployments, names);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading AI deployment configuration.");
}
- _configDeployments = deployments;
- return _configDeployments;
+ return deployments.Values.ToArray();
}
- private void ReadConnectionDeployments(List deployments)
+ private void ReadStandaloneDeployments(Dictionary deployments, Dictionary names)
{
- foreach (var(providerName, provider)in _providerOptions.Providers)
+ foreach (var sectionPath in _catalogOptions.DeploymentSections)
{
- if (provider.Connections is null)
+ var section = _configuration.GetSection(sectionPath);
+ if (!section.Exists())
{
continue;
}
- foreach (var(connectionId, connectionEntry)in provider.Connections)
+ var deploymentsNode = ReadConfigurationNode(section);
+ switch (deploymentsNode)
{
- if (!connectionEntry.TryGetValue("Deployments", out var deploymentsObject))
- {
- continue;
- }
-
- var deploymentArray = ConvertToJsonArray(deploymentsObject);
- if (deploymentArray is null)
- {
- continue;
- }
-
- foreach (var deploymentNode in deploymentArray)
- {
- if (deploymentNode is not JsonObject deploymentObject)
- {
- continue;
- }
-
- var deployment = ParseConnectionDeploymentEntry(deploymentObject, providerName, connectionId, connectionEntry);
- if (deployment != null)
- {
- deployments.Add(deployment);
- }
- }
+ case JsonArray deploymentArray:
+ ReadStandaloneDeploymentsFromArray(deploymentArray, deployments, names, sectionPath);
+ break;
+ case JsonObject deploymentObject:
+ ReadStandaloneDeploymentsFromObject(deploymentObject, deployments, names, sectionPath);
+ break;
+ case null:
+ break;
+ default:
+ _logger.LogWarning("The AI deployments configuration at '{SectionPath}' must be either an array or an object.", sectionPath);
+ break;
}
}
}
- private void ReadStandaloneDeployments(List deployments)
- {
- var section = GetDeploymentsSection();
- if (section is null)
- {
- return;
- }
-
- var deploymentsNode = ReadConfigurationNode(section);
- switch (deploymentsNode)
- {
- case JsonArray deploymentArray:
- ReadStandaloneDeploymentsFromArray(deploymentArray, deployments);
- break;
- case JsonObject deploymentObject:
- ReadStandaloneDeploymentsFromObject(deploymentObject, deployments);
- break;
- case null:
- break;
- default:
- _logger.LogWarning("The AI deployments configuration must be either an array or an object.");
- break;
- }
- }
-
- private IConfigurationSection GetDeploymentsSection()
- {
- var section = _configuration.GetSection("CrestApps:AI:Deployments");
- if (section.Exists())
- {
- return section;
- }
-
- section = _configuration.GetSection("CrestApps_AI:Deployments");
- return section.Exists() ? section : null;
- }
-
- private void ReadStandaloneDeploymentsFromArray(JsonArray deploymentArray, List deployments)
+ private void ReadStandaloneDeploymentsFromArray(JsonArray deploymentArray, Dictionary deployments, Dictionary names, string sectionPath)
{
foreach (var deploymentNode in deploymentArray)
{
@@ -242,16 +203,13 @@ private void ReadStandaloneDeploymentsFromArray(JsonArray deploymentArray, List<
}
var deployment = CreateStandaloneDeployment(ParseStandaloneDeploymentEntry(deploymentObject));
- if (deployment != null)
- {
- deployments.Add(deployment);
- }
+ AddDeployment(deployments, names, deployment, sectionPath);
}
}
- private void ReadStandaloneDeploymentsFromObject(JsonObject deploymentObject, List deployments)
+ private void ReadStandaloneDeploymentsFromObject(JsonObject deploymentObject, Dictionary deployments, Dictionary names, string sectionPath)
{
- foreach (var(providerName, providerDeploymentsNode)in deploymentObject)
+ foreach (var (providerName, providerDeploymentsNode) in deploymentObject)
{
if (providerDeploymentsNode is not JsonArray providerDeployments)
{
@@ -268,44 +226,11 @@ private void ReadStandaloneDeploymentsFromObject(JsonObject deploymentObject, Li
}
var deployment = CreateStandaloneDeployment(ParseStandaloneDeploymentEntry(standaloneDeploymentObject, providerName));
- if (deployment != null)
- {
- deployments.Add(deployment);
- }
+ AddDeployment(deployments, names, deployment, $"{sectionPath}:{providerName}");
}
}
}
- private AIDeployment ParseConnectionDeploymentEntry(JsonObject deploymentObject, string providerName, string connectionId, AIProviderConnectionEntry connectionEntry)
- {
- var name = GetStringValue(deploymentObject["Name"]);
- var modelName = GetStringValue(deploymentObject["ModelName"]) ?? name;
- if (string.IsNullOrWhiteSpace(name))
- {
- _logger.LogWarning("A deployment entry in connection '{ConnectionId}' of provider '{ProviderName}' is missing a Name. Skipping.", connectionId, providerName);
- return null;
- }
-
- if (!TryGetDeploymentType(deploymentObject["Type"], out var type))
- {
- _logger.LogWarning("Deployment entry '{Name}' in connection '{ConnectionId}' of provider '{ProviderName}' has an invalid or missing Type. Skipping.", name, connectionId, providerName);
- return null;
- }
-
- var connectionNameAlias = connectionEntry.TryGetValue("ConnectionNameAlias", out var aliasValue) ? aliasValue?.ToString() : null;
- return new AIDeployment
- {
- ItemId = AIConfigurationRecordIds.CreateDeploymentId(providerName, connectionId, name),
- Name = name,
- ModelName = modelName,
- Source = providerName,
- ConnectionName = connectionId,
- ConnectionNameAlias = connectionNameAlias,
- Type = type,
- IsDefault = GetBooleanValue(deploymentObject["IsDefault"]),
- };
- }
-
private static AIDeploymentConfigurationEntry ParseStandaloneDeploymentEntry(JsonObject deploymentObject, string providerName = null)
{
var entry = new AIDeploymentConfigurationEntry
@@ -363,7 +288,6 @@ private AIDeployment CreateStandaloneDeployment(AIDeploymentConfigurationEntry e
ModelName = entry.ModelName,
Source = entry.ProviderName,
Type = entry.Type,
- IsDefault = entry.IsDefault,
Properties = entry.Properties?.Count > 0 ? JsonSerializer.Deserialize>(entry.Properties.DeepClone()) : null,
};
}
@@ -469,7 +393,7 @@ private static JsonObject BuildStandaloneDeploymentProperties(JsonObject deploym
properties = (JsonObject)explicitProperties.DeepClone();
}
- foreach (var(key, value)in deploymentObject)
+ foreach (var (key, value) in deploymentObject)
{
if (IsStandaloneDeploymentMetadataKey(key))
{
@@ -516,6 +440,31 @@ private static bool GetBooleanValue(JsonNode node)
return false;
}
+ private void AddDeployment(
+ Dictionary deployments,
+ Dictionary names,
+ AIDeployment deployment,
+ string sourceDescription)
+ {
+ if (deployment == null)
+ {
+ return;
+ }
+
+ if (names.TryGetValue(deployment.Name, out var existingItemId) &&
+ !string.Equals(existingItemId, deployment.ItemId, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogWarning(
+ "Skipping AI deployment '{DeploymentName}' from {SourceDescription} because another deployment with the same name is already defined.",
+ deployment.Name,
+ sourceDescription);
+ return;
+ }
+
+ names[deployment.Name] = deployment.ItemId;
+ deployments[deployment.ItemId] = deployment;
+ }
+
private static List Merge(IReadOnlyCollection primary, IReadOnlyCollection secondary)
{
var merged = new List(primary.Count + secondary.Count);
@@ -556,4 +505,4 @@ private static IEnumerable ApplyFilters(QueryContext context, IEnu
return records;
}
-}
\ No newline at end of file
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs
new file mode 100644
index 00000000..71784888
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs
@@ -0,0 +1,393 @@
+using System.Globalization;
+using CrestApps.Core.AI.Models;
+using CrestApps.Core.Infrastructure;
+using CrestApps.Core.Models;
+using CrestApps.Core.Services;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace CrestApps.Core.AI.Services;
+
+///
+/// Decorates a persisted AI provider connection store with configuration-backed
+/// connections from appsettings.json. Read operations return the merged result
+/// while write operations continue to target the persisted store only.
+///
+public sealed class ConfigurationAIProviderConnectionCatalog : INamedSourceCatalog
+{
+ public const string PersistedCatalogKey = "PersistedCatalog";
+
+ private readonly INamedSourceCatalog _inner;
+ private readonly IConfiguration _configuration;
+ private readonly AIProviderConnectionCatalogOptions _options;
+ private readonly ILogger _logger;
+
+ public ConfigurationAIProviderConnectionCatalog(
+ [FromKeyedServices(PersistedCatalogKey)] INamedSourceCatalog inner,
+ IConfiguration configuration,
+ IOptions options,
+ ILogger logger)
+ {
+ _inner = inner;
+ _configuration = configuration;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public async ValueTask FindByIdAsync(string id)
+ {
+ var result = await _inner.FindByIdAsync(id);
+ if (result != null)
+ {
+ return result;
+ }
+
+ return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync()))
+ .FirstOrDefault(connection => string.Equals(connection.ItemId, id, StringComparison.OrdinalIgnoreCase))
+ ?.Clone();
+ }
+
+ public async ValueTask> GetAllAsync()
+ {
+ var storedConnections = await _inner.GetAllAsync();
+ var configuredConnections = await GetConfiguredConnectionsAsync(storedConnections);
+
+ if (configuredConnections.Count == 0)
+ {
+ return storedConnections;
+ }
+
+ var merged = new List(storedConnections.Count + configuredConnections.Count);
+ merged.AddRange(storedConnections);
+ merged.AddRange(configuredConnections);
+
+ return merged;
+ }
+
+ public async ValueTask> GetAsync(IEnumerable ids)
+ {
+ var storedConnections = await _inner.GetAsync(ids);
+ var requestedIds = ids.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var foundIds = storedConnections.Select(static connection => connection.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var missingIds = requestedIds.Except(foundIds).ToList();
+
+ if (missingIds.Count == 0)
+ {
+ return storedConnections;
+ }
+
+ var configuredConnections = (await GetConfiguredConnectionsAsync(storedConnections))
+ .Where(connection => missingIds.Contains(connection.ItemId))
+ .ToArray();
+
+ if (configuredConnections.Length == 0)
+ {
+ return storedConnections;
+ }
+
+ var merged = new List(storedConnections.Count + configuredConnections.Length);
+ merged.AddRange(storedConnections);
+ merged.AddRange(configuredConnections);
+
+ return merged;
+ }
+
+ public async ValueTask> PageAsync(int page, int pageSize, TQuery context)
+ where TQuery : QueryContext
+ {
+ var allConnections = await GetAllAsync();
+ var filtered = ApplyFilters(context, allConnections);
+ var skip = (page - 1) * pageSize;
+
+ return new PageResult
+ {
+ Count = filtered.Count(),
+ Entries = filtered.Skip(skip).Take(pageSize).ToArray(),
+ };
+ }
+
+ public async ValueTask FindByNameAsync(string name)
+ {
+ var result = await _inner.FindByNameAsync(name);
+ if (result != null)
+ {
+ return result;
+ }
+
+ return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync()))
+ .FirstOrDefault(connection => string.Equals(connection.Name, name, StringComparison.OrdinalIgnoreCase))
+ ?.Clone();
+ }
+
+ public async ValueTask> GetAsync(string source)
+ {
+ var storedConnections = await _inner.GetAsync(source);
+ var configuredConnections = (await GetConfiguredConnectionsAsync(storedConnections))
+ .Where(connection => string.Equals(connection.Source, source, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ if (configuredConnections.Length == 0)
+ {
+ return storedConnections;
+ }
+
+ var merged = new List(storedConnections.Count + configuredConnections.Length);
+ merged.AddRange(storedConnections);
+ merged.AddRange(configuredConnections);
+
+ return merged;
+ }
+
+ public async ValueTask GetAsync(string name, string source)
+ {
+ var result = await _inner.GetAsync(name, source);
+ if (result != null)
+ {
+ return result;
+ }
+
+ return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync()))
+ .FirstOrDefault(connection =>
+ string.Equals(connection.Name, name, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(connection.Source, source, StringComparison.OrdinalIgnoreCase))
+ ?.Clone();
+ }
+
+ public ValueTask DeleteAsync(AIProviderConnection entry) => _inner.DeleteAsync(entry);
+
+ public ValueTask CreateAsync(AIProviderConnection entry) => _inner.CreateAsync(entry);
+
+ public ValueTask UpdateAsync(AIProviderConnection entry) => _inner.UpdateAsync(entry);
+
+ private Task> GetConfiguredConnectionsAsync(IReadOnlyCollection storedConnections)
+ {
+ var connections = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var names = storedConnections
+ .Where(static connection => !string.IsNullOrWhiteSpace(connection.Name))
+ .ToDictionary(static connection => connection.Name, static connection => connection.ItemId, StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ foreach (var sectionPath in _options.ConnectionSections)
+ {
+ ReadTopLevelConnections(sectionPath, connections, names);
+ }
+
+ foreach (var sectionPath in _options.ProviderSections)
+ {
+ ReadProviderConnections(sectionPath, connections, names);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error reading AI provider connection configuration.");
+ }
+
+ return Task.FromResult>(connections.Values.ToArray());
+ }
+
+ private void ReadTopLevelConnections(string sectionPath, Dictionary connections, Dictionary names)
+ {
+ var section = _configuration.GetSection(sectionPath);
+ if (!section.Exists())
+ {
+ return;
+ }
+
+ foreach (var connectionSection in section.GetChildren())
+ {
+ var values = ReadObject(connectionSection);
+ var connection = ParseConnection(values, fallbackName: connectionSection.Key);
+ AddConfiguredConnection(connections, names, connection, sectionPath);
+ }
+ }
+
+ private void ReadProviderConnections(string sectionPath, Dictionary connections, Dictionary names)
+ {
+ var section = _configuration.GetSection(sectionPath);
+ if (!section.Exists())
+ {
+ return;
+ }
+
+ 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 values = ReadObject(connectionSection);
+ var connection = ParseConnection(values, fallbackName: connectionSection.Key, providerName: providerName);
+ AddConfiguredConnection(connections, names, connection, $"{sectionPath}:{providerSection.Key}:Connections:{connectionSection.Key}");
+ }
+ }
+ }
+
+ private void AddConfiguredConnection(
+ Dictionary connections,
+ Dictionary names,
+ AIProviderConnection connection,
+ string sourceDescription)
+ {
+ if (connection == null)
+ {
+ return;
+ }
+
+ if (names.TryGetValue(connection.Name, out var existingItemId) &&
+ !string.Equals(existingItemId, connection.ItemId, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogWarning(
+ "Skipping AI connection '{ConnectionName}' from '{SourceDescription}' because another connection with the same name is already defined.",
+ connection.Name,
+ sourceDescription);
+ return;
+ }
+
+ names[connection.Name] = connection.ItemId;
+ connections[connection.ItemId] = connection;
+ }
+
+ private AIProviderConnection ParseConnection(
+ Dictionary values,
+ string fallbackName = null,
+ string providerName = null)
+ {
+ var connectionName = values.GetStringValue("Name", false) ?? fallbackName;
+ var clientName = AIProviderNameNormalizer.Normalize(
+ values.GetStringValue("ClientName", false) ??
+ values.GetStringValue("ProviderName", false) ??
+ providerName);
+
+ if (string.IsNullOrWhiteSpace(connectionName))
+ {
+ _logger.LogWarning("An AI connection configuration entry is missing the required Name value and will be ignored.");
+ return null;
+ }
+
+ if (string.IsNullOrWhiteSpace(clientName))
+ {
+ _logger.LogWarning("The AI connection '{ConnectionName}' is missing the required ClientName value and will be ignored.", connectionName);
+ return null;
+ }
+
+ var displayText = values.GetStringValue("DisplayText", false);
+ var properties = values
+ .Where(static pair => !IsConnectionMetadataKey(pair.Key))
+ .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase);
+
+ return new AIProviderConnection
+ {
+ ItemId = AIConfigurationRecordIds.CreateConnectionId(clientName, connectionName),
+ Name = connectionName,
+ DisplayText = string.IsNullOrWhiteSpace(displayText) ? connectionName : displayText,
+ ClientName = clientName,
+ Properties = properties.Count > 0 ? properties : null,
+ };
+ }
+
+ private static bool IsConnectionMetadataKey(string key)
+ {
+ return string.Equals(key, "Name", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "ClientName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "ProviderName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "DisplayText", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "ConnectionNameAlias", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "ChatDeploymentName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "DefaultChatDeploymentName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "DefaultDeploymentName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "EmbeddingDeploymentName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "ImagesDeploymentName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "UtilityDeploymentName", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(key, "SpeechToTextDeploymentName", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static Dictionary ReadObject(IConfigurationSection section)
+ {
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var child in section.GetChildren())
+ {
+ values[child.Key] = ReadValue(child);
+ }
+
+ return values;
+ }
+
+ private static object ReadValue(IConfigurationSection section)
+ {
+ var children = section.GetChildren().ToArray();
+
+ if (children.Length == 0)
+ {
+ return ParseScalar(section.Value);
+ }
+
+ if (children.All(static child => int.TryParse(child.Key, out _)))
+ {
+ return children
+ .OrderBy(static child => int.Parse(child.Key, CultureInfo.InvariantCulture))
+ .Select(ReadValue)
+ .ToArray();
+ }
+
+ return children.ToDictionary(static child => child.Key, ReadValue, StringComparer.OrdinalIgnoreCase);
+ }
+
+ private static object ParseScalar(string value)
+ {
+ if (bool.TryParse(value, out var booleanValue))
+ {
+ return booleanValue;
+ }
+
+ if (int.TryParse(value, out var intValue))
+ {
+ return intValue;
+ }
+
+ if (long.TryParse(value, out var longValue))
+ {
+ return longValue;
+ }
+
+ if (double.TryParse(value, out var doubleValue))
+ {
+ return doubleValue;
+ }
+
+ return value;
+ }
+
+ private static IEnumerable ApplyFilters(QueryContext context, IEnumerable records)
+ {
+ if (context is null)
+ {
+ return records;
+ }
+
+ if (!string.IsNullOrEmpty(context.Source))
+ {
+ records = records.Where(connection => string.Equals(connection.Source, context.Source, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (!string.IsNullOrEmpty(context.Name))
+ {
+ records = records.Where(connection => connection.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (context.Sorted)
+ {
+ records = records.OrderBy(static connection => connection.DisplayText ?? connection.Name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ return records;
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs
index 6e4594eb..acb5ed6d 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs
@@ -71,10 +71,11 @@ private static AIProviderConnectionEntry ReadConnection(IConfigurationSection se
{
var values = ReadObject(section);
- if (!values.ContainsKey("ConnectionNameAlias"))
- {
- values["ConnectionNameAlias"] = values.GetStringValue("Name", false) ?? section.Key;
- }
+ var displayText = values.GetStringValue("DisplayText", false) ??
+ values.GetStringValue("Name", false) ??
+ section.Key;
+
+ values["DisplayText"] = displayText;
return new AIProviderConnectionEntry(values);
}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs
index 02af26ff..34577ba3 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs
@@ -1,15 +1,15 @@
using CrestApps.Core.AI.Clients;
using CrestApps.Core.AI.Models;
+using CrestApps.Core.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.Services;
public sealed class DefaultAIClientFactory : IAIClientFactory
{
- private readonly AIProviderOptions _options;
+ private readonly INamedSourceCatalog _connectionCatalog;
private readonly IEnumerable _clientProviders;
private readonly IDataProtectionProvider _dataProtectionProvider;
@@ -21,9 +21,9 @@ public DefaultAIClientFactory(
IDataProtectionProvider dataProtectionProvider,
IServiceProvider serviceProvider,
ILogger logger,
- IOptions options)
+ INamedSourceCatalog connectionCatalog)
{
- _options = options.Value;
+ _connectionCatalog = connectionCatalog;
_clientProviders = clientProviders;
_dataProtectionProvider = dataProtectionProvider;
_serviceProvider = serviceProvider;
@@ -36,17 +36,7 @@ public async ValueTask CreateChatClientAsync(string providerName, s
ArgumentException.ThrowIfNullOrEmpty(connectionName);
- if (!_options.Providers.TryGetValue(providerName, out var provider))
- {
- throw new ArgumentException($"Provider '{providerName}' not found.");
-
- }
-
- if (!provider.Connections.TryGetValue(connectionName, out var connection))
- {
- throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'.");
-
- }
+ var connection = await GetConnectionEntryAsync(providerName, connectionName);
foreach (var clientProvider in _clientProviders)
{
@@ -72,23 +62,13 @@ public async ValueTask CreateChatClientAsync(string providerName, s
}
- public ValueTask>> CreateEmbeddingGeneratorAsync(string providerName, string connectionName, string deploymentName = null)
+ public async ValueTask>> CreateEmbeddingGeneratorAsync(string providerName, string connectionName, string deploymentName = null)
{
ArgumentException.ThrowIfNullOrEmpty(providerName);
ArgumentException.ThrowIfNullOrEmpty(connectionName);
- if (!_options.Providers.TryGetValue(providerName, out var provider))
- {
- throw new ArgumentException($"Provider '{providerName}' not found.");
-
- }
-
- if (!provider.Connections.TryGetValue(connectionName, out var connection))
- {
- throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'.");
-
- }
+ var connection = await GetConnectionEntryAsync(providerName, connectionName);
foreach (var clientProvider in _clientProviders)
{
@@ -98,7 +78,7 @@ public ValueTask>> CreateEmbeddingG
}
- return clientProvider.GetEmbeddingGeneratorAsync(connection, deploymentName);
+ return await clientProvider.GetEmbeddingGeneratorAsync(connection, deploymentName);
}
@@ -107,24 +87,14 @@ public ValueTask>> CreateEmbeddingG
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- public ValueTask CreateImageGeneratorAsync(string providerName, string connectionName, string deploymentName = null)
+ public async ValueTask CreateImageGeneratorAsync(string providerName, string connectionName, string deploymentName = null)
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
{
ArgumentException.ThrowIfNullOrEmpty(providerName);
ArgumentException.ThrowIfNullOrEmpty(connectionName);
- if (!_options.Providers.TryGetValue(providerName, out var provider))
- {
- throw new ArgumentException($"Provider '{providerName}' not found.");
-
- }
-
- if (!provider.Connections.TryGetValue(connectionName, out var connection))
- {
- throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'.");
-
- }
+ var connection = await GetConnectionEntryAsync(providerName, connectionName);
foreach (var clientProvider in _clientProviders)
{
@@ -134,7 +104,7 @@ public ValueTask CreateImageGeneratorAsync(string providerName,
}
- return clientProvider.GetImageGeneratorAsync(connection, deploymentName);
+ return await clientProvider.GetImageGeneratorAsync(connection, deploymentName);
}
@@ -143,24 +113,14 @@ public ValueTask CreateImageGeneratorAsync(string providerName,
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- public ValueTask CreateSpeechToTextClientAsync(string providerName, string connectionName, string deploymentName = null)
+ public async ValueTask CreateSpeechToTextClientAsync(string providerName, string connectionName, string deploymentName = null)
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
{
ArgumentException.ThrowIfNullOrEmpty(providerName);
ArgumentException.ThrowIfNullOrEmpty(connectionName);
- if (!_options.Providers.TryGetValue(providerName, out var provider))
- {
- throw new ArgumentException($"Provider '{providerName}' not found.");
-
- }
-
- if (!provider.Connections.TryGetValue(connectionName, out var connection))
- {
- throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'.");
-
- }
+ var connection = await GetConnectionEntryAsync(providerName, connectionName);
foreach (var clientProvider in _clientProviders)
{
@@ -170,7 +130,7 @@ public ValueTask CreateSpeechToTextClientAsync(string provi
}
- return clientProvider.GetSpeechToTextClientAsync(connection, deploymentName);
+ return await clientProvider.GetSpeechToTextClientAsync(connection, deploymentName);
}
@@ -214,23 +174,13 @@ public ValueTask CreateSpeechToTextClientAsync(AIDeployment
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- public ValueTask CreateTextToSpeechClientAsync(string providerName, string connectionName, string deploymentName = null)
+ public async ValueTask CreateTextToSpeechClientAsync(string providerName, string connectionName, string deploymentName = null)
{
ArgumentException.ThrowIfNullOrEmpty(providerName);
ArgumentException.ThrowIfNullOrEmpty(connectionName);
- if (!_options.Providers.TryGetValue(providerName, out var provider))
- {
- throw new ArgumentException($"Provider '{providerName}' not found.");
-
- }
-
- if (!provider.Connections.TryGetValue(connectionName, out var connection))
- {
- throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'.");
-
- }
+ var connection = await GetConnectionEntryAsync(providerName, connectionName);
foreach (var clientProvider in _clientProviders)
{
@@ -240,7 +190,7 @@ public ValueTask CreateTextToSpeechClientAsync(string provi
}
- return clientProvider.GetTextToSpeechClientAsync(connection, deploymentName);
+ return await clientProvider.GetTextToSpeechClientAsync(connection, deploymentName);
}
@@ -281,4 +231,16 @@ public ValueTask CreateTextToSpeechClientAsync(AIDeployment
}
#pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+ private async ValueTask GetConnectionEntryAsync(string providerName, string connectionName)
+ {
+ var connection = await _connectionCatalog.GetAsync(connectionName, providerName);
+
+ if (connection == null)
+ {
+ throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'.");
+ }
+
+ return AIProviderConnectionEntryFactory.Create(connection);
+ }
}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs
index 415e8dc5..f4ec87a5 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs
@@ -11,6 +11,7 @@
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Services;
+
public sealed class DefaultAIDataSourceIndexingService : IAIDataSourceIndexingService
{
private const int BatchSize = 250;
@@ -45,7 +46,7 @@ public async Task SyncAllAsync(CancellationToken cancellationToken = default)
{
await SyncDataSourceAsync(dataSource, cancellationToken);
}
- catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs
index fc961abc..8cb94690 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs
@@ -24,8 +24,7 @@ public async ValueTask> GetAllAsync(string clientName,
{
var deployments = (await Catalog.GetAllAsync())
.Where(x => string.Equals(x.ClientName, clientName, StringComparison.OrdinalIgnoreCase) &&
- (string.Equals(x.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase) ||
- string.Equals(x.ConnectionNameAlias ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase)));
+ string.Equals(x.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase));
foreach (var deployment in deployments)
{
@@ -54,8 +53,7 @@ public async ValueTask GetDefaultAsync(string clientName, string c
var candidates = deployments.Where(d => d.SupportsType(type));
- return candidates.FirstOrDefault(d => d.IsDefault)
- ?? candidates.FirstOrDefault();
+ return candidates.FirstOrDefault();
}
public ValueTask ResolveOrDefaultAsync(AIDeploymentType type, string deploymentName = null, string clientName = null, string connectionName = null)
@@ -126,8 +124,7 @@ private async ValueTask GetFirstMatchingDeploymentAsync(AIDeployme
return true;
}
- return string.Equals(deployment.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase) ||
- string.Equals(deployment.ConnectionNameAlias ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase);
+ return string.Equals(deployment.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase);
});
}
diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs
index 82496ad1..56ce82fa 100644
--- a/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs
+++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs
@@ -1,25 +1,25 @@
using CrestApps.Core.AI.Clients;
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Speech;
+using CrestApps.Core.Services;
using Microsoft.AspNetCore.DataProtection;
-using Microsoft.Extensions.Options;
namespace CrestApps.Core.AI.Services;
public sealed class DefaultSpeechVoiceResolver : ISpeechVoiceResolver
{
private readonly IEnumerable _clientProviders;
- private readonly AIProviderOptions _options;
+ private readonly INamedSourceCatalog _connectionCatalog;
private readonly IDataProtectionProvider _dataProtectionProvider;
public DefaultSpeechVoiceResolver(
IEnumerable clientProviders,
IDataProtectionProvider dataProtectionProvider,
- IOptions options)
+ INamedSourceCatalog connectionCatalog)
{
_clientProviders = clientProviders;
_dataProtectionProvider = dataProtectionProvider;
- _options = options.Value;
+ _connectionCatalog = connectionCatalog;
}
public async Task GetSpeechVoicesAsync(AIDeployment deployment)
@@ -27,7 +27,7 @@ public async Task GetSpeechVoicesAsync(AIDeployment deployment)
ArgumentNullException.ThrowIfNull(deployment);
ArgumentException.ThrowIfNullOrEmpty(deployment.ClientName);
- var connectionEntry = GetConnectionEntry(deployment);
+ var connectionEntry = await GetConnectionEntryAsync(deployment);
foreach (var clientProvider in _clientProviders)
{
@@ -42,14 +42,14 @@ public async Task GetSpeechVoicesAsync(AIDeployment deployment)
return [];
}
- private AIProviderConnectionEntry GetConnectionEntry(AIDeployment deployment)
+ private async ValueTask GetConnectionEntryAsync(AIDeployment deployment)
{
if (!string.IsNullOrEmpty(deployment.ConnectionName))
{
- if (_options.Providers.TryGetValue(deployment.ClientName, out var provider)
- && provider.Connections.TryGetValue(deployment.ConnectionName, out var connection))
+ var connection = await _connectionCatalog.GetAsync(deployment.ConnectionName, deployment.ClientName);
+ if (connection != null)
{
- return connection;
+ return AIProviderConnectionEntryFactory.Create(connection);
}
throw new InvalidOperationException(
diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs
index 8cc2fd47..53651fdb 100644
--- a/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs
@@ -16,6 +16,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.Azure.AISearch;
+
public static class ServiceCollectionExtensions
{
public const string ProviderName = "AzureAISearch";
diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs
index 0808e20e..da43653e 100644
--- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs
+++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs
@@ -116,7 +116,7 @@ public async Task DeleteAllAsync(IIndexProfileInfo profile, CancellationToken ca
var keysToDelete = new List();
await foreach (var result in response.Value.GetResultsAsync())
{
- if (result.Document.TryGetValue(keyFieldName, out var keyObj) && keyObj?.ToString()is string key && !string.IsNullOrEmpty(key))
+ if (result.Document.TryGetValue(keyFieldName, out var keyObj) && keyObj?.ToString() is string key && !string.IsNullOrEmpty(key))
{
keysToDelete.Add(key);
}
diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs
index 28702126..de6bdc90 100644
--- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs
+++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs
@@ -54,7 +54,7 @@ public async Task ExistsAsync(IIndexProfileInfo profile, CancellationToken
await _searchIndexClient.GetIndexAsync(indexFullName, cancellationToken);
return true;
}
- catch (RequestFailedException ex)when (ex.Status == 404)
+ catch (RequestFailedException ex) when (ex.Status == 404)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
@@ -142,9 +142,9 @@ public async Task DeleteAsync(IIndexProfileInfo profile, CancellationToken cance
{
await _searchIndexClient.DeleteIndexAsync(indexFullName, cancellationToken);
}
- catch (RequestFailedException ex)when (ex.Status == 404)
+ catch (RequestFailedException ex) when (ex.Status == 404)
{
- // Index already deleted, nothing to do.
+ // Index already deleted, nothing to do.
}
catch (Exception ex)
{
diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs
index 6d4850f3..ad1e60b2 100644
--- a/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs
@@ -2,8 +2,8 @@
using CrestApps.Core.AI.Indexing;
using CrestApps.Core.AI.Memory;
using CrestApps.Core.Builders;
-using CrestApps.Core.Elasticsearch.Services;
using CrestApps.Core.Elasticsearch.Builders;
+using CrestApps.Core.Elasticsearch.Services;
using CrestApps.Core.Infrastructure.Indexing;
using CrestApps.Core.Infrastructure.Indexing.DataSources;
using Elastic.Clients.Elasticsearch;
@@ -14,6 +14,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.Elasticsearch;
+
public static class ServiceCollectionExtensions
{
public const string ProviderName = "Elasticsearch";
diff --git a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs
index 821ba743..288efae3 100644
--- a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs
+++ b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
namespace CrestApps.Core.Infrastructure;
+
public static class DictionaryExtensions
{
public static string GetApiKey(this IDictionary entry, bool throwException = true)
diff --git a/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs
index c7ce23b8..f21fc77a 100644
--- a/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs
@@ -1,5 +1,5 @@
-using CrestApps.Core.SignalR.Services;
using CrestApps.Core.Builders;
+using CrestApps.Core.SignalR.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
diff --git a/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs b/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs
index 363b3def..0c60de9e 100644
--- a/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs
+++ b/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs
@@ -2,6 +2,7 @@
using CrestApps.Core.Services;
namespace CrestApps.Core.Handlers;
+
public abstract class CatalogEntryHandlerBase : ICatalogEntryHandler
{
public virtual Task DeletedAsync(DeletedContext context)
diff --git a/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs
index c03abcd7..e78eae46 100644
--- a/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs
@@ -38,6 +38,16 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service
return services;
}
+ public static IServiceCollection AddCatalogManagers(this IServiceCollection services)
+ {
+ services.TryAddScoped(typeof(ICatalogManager<>), typeof(CatalogManager<>));
+ services.TryAddScoped(typeof(INamedCatalogManager<>), typeof(NamedCatalogManager<>));
+ services.TryAddScoped(typeof(ISourceCatalogManager<>), typeof(SourceCatalogManager<>));
+ services.TryAddScoped(typeof(INamedSourceCatalogManager<>), typeof(NamedSourceCatalogManager<>));
+
+ return services;
+ }
+
///
/// Registers as a global MVC action filter.
/// The filter commits all staged store writes after each controller action completes
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs
index 5040fe2a..d5ea7670 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs
@@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Mvc;
namespace CrestApps.Core.Mvc.Web.Areas.A2A.Controllers;
+
[Area("A2A")]
[Authorize(Policy = "Admin")]
public sealed class A2AConnectionController : Controller
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs
index 43330af6..99276651 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs
@@ -1,53 +1,34 @@
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Services;
-using CrestApps.Core.Mvc.Web.Areas.AI.Services;
using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels;
using CrestApps.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
-using Microsoft.Extensions.Options;
namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers;
+
[Area("AI")]
[Authorize(Policy = "Admin")]
public sealed class AIConnectionController : Controller
{
- private readonly ICatalog _catalog;
- private readonly IConfiguration _configuration;
- private readonly MvcAIProviderOptionsStore _providerOptionsStore;
- private readonly IOptionsMonitorCache _providerOptionsCache;
- private static readonly List _providers = [new("OpenAI", "OpenAI"), new("Azure OpenAI", "Azure"), new("Azure AI Inference (GitHub Models)", "AzureAIInference"), new("Ollama", "Ollama"), ];
- private static readonly List _authTypes = [new("API Key", "ApiKey"), new("Default Azure Credential", "Default"), new("Managed Identity", "ManagedIdentity"), ];
- public AIConnectionController(ICatalog catalog, IConfiguration configuration, MvcAIProviderOptionsStore providerOptionsStore, IOptionsMonitorCache providerOptionsCache)
+ private readonly INamedSourceCatalog _catalog;
+ private static readonly List _providers = [new("OpenAI", "OpenAI"), new("Azure OpenAI", "Azure"), new("Azure AI Inference (GitHub Models)", "AzureAIInference"), new("Ollama", "Ollama"),];
+ private static readonly List _authTypes = [new("API Key", "ApiKey"), new("Default Azure Credential", "Default"), new("Managed Identity", "ManagedIdentity"),];
+ public AIConnectionController(INamedSourceCatalog catalog)
{
_catalog = catalog;
- _configuration = configuration;
- _providerOptionsStore = providerOptionsStore;
- _providerOptionsCache = providerOptionsCache;
}
public async Task Index()
{
var connections = await _catalog.GetAllAsync();
- var configuredConnections = GetConfiguredConnections();
- var configuredKeys = configuredConnections.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var models = connections.Select(connection =>
{
var model = AIConnectionViewModel.FromConnection(connection);
- model.IsReadOnly = configuredKeys.Contains(BuildConnectionKey(connection.Source, connection.Name));
+ model.IsReadOnly = AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId);
return model;
}).ToList();
- var existingKeys = models.Select(static model => BuildConnectionKey(model.Source, model.Name)).ToHashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var(key, connection)in configuredConnections)
- {
- if (existingKeys.Contains(key))
- {
- continue;
- }
-
- models.Add(AIConnectionViewModel.FromConfiguration(AIConfigurationRecordIds.CreateConnectionId(connection.ProviderName, connection.ConnectionName), connection.ConnectionName, connection.DisplayText, connection.ProviderName));
- }
return View(models.OrderBy(static model => model.DisplayText ?? model.Name, StringComparer.OrdinalIgnoreCase).ToList());
}
@@ -76,6 +57,8 @@ public async Task Create(AIConnectionViewModel model)
ModelState.AddModelError(nameof(model.Source), "Provider is required.");
}
+ await ValidateUniqueNameAsync(model.Name);
+
if (!ModelState.IsValid)
{
model.Providers = _providers;
@@ -92,7 +75,6 @@ public async Task Create(AIConnectionViewModel model)
connection.CreatedUtc = DateTime.UtcNow;
model.ApplyTo(connection);
await _catalog.CreateAsync(connection);
- await RefreshProviderOptionsAsync();
return RedirectToAction(nameof(Index));
}
@@ -110,7 +92,7 @@ public async Task Edit(string id)
return NotFound();
}
- if (IsConfigurationBacked(connection))
+ if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId))
{
TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI.";
return RedirectToAction(nameof(Index));
@@ -137,6 +119,8 @@ public async Task Edit(AIConnectionViewModel model)
ModelState.AddModelError(nameof(model.Name), "Name is required.");
}
+ await ValidateUniqueNameAsync(model.Name, model.ItemId);
+
if (!ModelState.IsValid)
{
model.Providers = _providers;
@@ -150,7 +134,7 @@ public async Task Edit(AIConnectionViewModel model)
return NotFound();
}
- if (IsConfigurationBacked(existing))
+ if (AIConfigurationRecordIds.IsConfigurationConnectionId(existing.ItemId))
{
TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI.";
return RedirectToAction(nameof(Index));
@@ -158,7 +142,6 @@ public async Task Edit(AIConnectionViewModel model)
model.ApplyTo(existing);
await _catalog.UpdateAsync(existing);
- await RefreshProviderOptionsAsync();
return RedirectToAction(nameof(Index));
}
@@ -178,98 +161,27 @@ public async Task Delete(string id)
return NotFound();
}
- if (IsConfigurationBacked(connection))
+ if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId))
{
TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be deleted from the UI.";
return RedirectToAction(nameof(Index));
}
await _catalog.DeleteAsync(connection);
- await RefreshProviderOptionsAsync();
return RedirectToAction(nameof(Index));
}
- private async Task RefreshProviderOptionsAsync()
- {
- _providerOptionsStore.Replace(await _catalog.GetAllAsync());
- _providerOptionsCache.TryRemove(Options.DefaultName);
- }
-
- private bool IsConfigurationBacked(AIProviderConnection connection)
- {
- return GetConfiguredConnections().ContainsKey(BuildConnectionKey(connection.Source, connection.Name));
- }
-
- private Dictionary GetConfiguredConnections()
- {
- var connections = new Dictionary(StringComparer.OrdinalIgnoreCase);
- ReadTopLevelConnections(connections);
- ReadProviderConnections("CrestApps:Providers", connections);
- ReadProviderConnections("CrestApps:AI:Providers", connections);
- return connections;
- }
-
- private void ReadTopLevelConnections(Dictionary connections)
- {
- var section = _configuration.GetSection("CrestApps:AI:Connections");
- if (!section.Exists())
- {
- return;
- }
-
- foreach (var connectionSection in section.GetChildren())
- {
- var connectionName = connectionSection["Name"];
- var providerName = AIProviderNameNormalizer.Normalize(connectionSection["ClientName"]);
- if (string.IsNullOrWhiteSpace(connectionName) || string.IsNullOrWhiteSpace(providerName))
- {
- continue;
- }
-
- var displayText = connectionSection["ConnectionNameAlias"];
- AddConfiguredConnection(connections, providerName, connectionName, displayText);
- }
- }
-
- private void ReadProviderConnections(string sectionPath, Dictionary connections)
+ private async Task ValidateUniqueNameAsync(string name, string currentItemId = null)
{
- var section = _configuration.GetSection(sectionPath);
- if (!section.Exists())
+ if (string.IsNullOrWhiteSpace(name))
{
return;
}
- foreach (var providerSection in section.GetChildren())
+ var existing = await _catalog.FindByNameAsync(name);
+ if (existing != null && !string.Equals(existing.ItemId, currentItemId, StringComparison.OrdinalIgnoreCase))
{
- var connectionsSection = providerSection.GetSection("Connections");
- if (!connectionsSection.Exists())
- {
- continue;
- }
-
- foreach (var connectionSection in connectionsSection.GetChildren())
- {
- if (string.IsNullOrWhiteSpace(connectionSection.Key))
- {
- continue;
- }
-
- AddConfiguredConnection(connections, AIProviderNameNormalizer.Normalize(providerSection.Key), connectionSection.Key, connectionSection["ConnectionNameAlias"]);
- }
+ ModelState.AddModelError(nameof(AIConnectionViewModel.Name), "Name must be unique across appsettings and UI connections.");
}
}
-
- private static void AddConfiguredConnection(Dictionary connections, string providerName, string connectionName, string displayText)
- {
- providerName = AIProviderNameNormalizer.Normalize(providerName);
- var key = BuildConnectionKey(providerName, connectionName);
- connections[key] = new ConfiguredConnectionEntry(providerName, connectionName, string.IsNullOrWhiteSpace(displayText) ? connectionName : displayText);
- }
-
- private static string BuildConnectionKey(string providerName, string connectionName)
- {
- return $"{AIProviderNameNormalizer.Normalize(providerName)}:{connectionName}";
- }
-
- private sealed record ConfiguredConnectionEntry(string ProviderName, string ConnectionName, string DisplayText);
-}
\ No newline at end of file
+}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs
index ed35b482..89af34b3 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs
@@ -1,12 +1,10 @@
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Services;
-using CrestApps.Core.Infrastructure;
using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels;
using CrestApps.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
-using Microsoft.Extensions.Options;
namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers;
@@ -14,8 +12,8 @@ namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers;
[Authorize(Policy = "Admin")]
public sealed class AIDeploymentController : Controller
{
- private readonly ICatalog _deploymentCatalog;
- private readonly AIProviderOptions _providerOptions;
+ private readonly INamedSourceCatalog _deploymentCatalog;
+ private readonly INamedSourceCatalog _connectionCatalog;
private static readonly List _providers =
[
@@ -40,11 +38,11 @@ public sealed class AIDeploymentController : Controller
};
public AIDeploymentController(
- ICatalog deploymentCatalog,
- IOptionsSnapshot providerOptions)
+ INamedSourceCatalog deploymentCatalog,
+ INamedSourceCatalog connectionCatalog)
{
_deploymentCatalog = deploymentCatalog;
- _providerOptions = providerOptions.Value;
+ _connectionCatalog = connectionCatalog;
}
public async Task Index()
@@ -90,6 +88,8 @@ public async Task Create(AIDeploymentViewModel model)
ModelState.AddModelError(nameof(model.SelectedTypes), "At least one deployment type is required.");
}
+ await ValidateUniqueNameAsync(model.TechnicalName);
+
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync(model);
@@ -163,6 +163,8 @@ public async Task Edit(AIDeploymentViewModel model)
ModelState.AddModelError(nameof(model.SelectedTypes), "At least one deployment type is required.");
}
+ await ValidateUniqueNameAsync(model.TechnicalName, model.ItemId);
+
if (!ModelState.IsValid)
{
await PopulateDropdownsAsync(model);
@@ -219,27 +221,27 @@ public async Task Delete(string id)
return RedirectToAction(nameof(Index));
}
- private Task PopulateDropdownsAsync(AIDeploymentViewModel model)
+ private async Task PopulateDropdownsAsync(AIDeploymentViewModel model)
{
var selectedProvider = string.IsNullOrWhiteSpace(model.ClientName)
? null
: model.ClientName;
- model.Connections = _providerOptions.Providers
- .Where(provider => selectedProvider is null || provider.Key.Equals(selectedProvider, StringComparison.OrdinalIgnoreCase))
- .Where(static provider => provider.Value.Connections is not null)
- .OrderBy(provider => provider.Key, StringComparer.OrdinalIgnoreCase)
- .SelectMany(provider => provider.Value.Connections
- .OrderBy(connection => connection.Value.GetStringValue("ConnectionNameAlias", false) ?? connection.Key, StringComparer.OrdinalIgnoreCase)
- .Select(connection =>
- {
- var connectionAlias = connection.Value.GetStringValue("ConnectionNameAlias", false) ?? connection.Key;
- var displayName = selectedProvider is null
- ? $"{connectionAlias} ({provider.Key})"
- : connectionAlias;
-
- return new SelectListItem(displayName, connection.Key);
- }))
+ var connections = await _connectionCatalog.GetAllAsync();
+
+ model.Connections = connections
+ .Where(connection => selectedProvider is null || string.Equals(connection.ClientName, selectedProvider, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(connection => connection.ClientName, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(connection => connection.DisplayText ?? connection.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(connection =>
+ {
+ var connectionAlias = connection.DisplayText ?? connection.Name;
+ var displayName = selectedProvider is null
+ ? $"{connectionAlias} ({connection.ClientName})"
+ : connectionAlias;
+
+ return new SelectListItem(displayName, connection.Name);
+ })
.ToList();
model.Providers = _providers;
model.AuthenticationTypes = _authTypes;
@@ -247,7 +249,19 @@ private Task PopulateDropdownsAsync(AIDeploymentViewModel model)
.Where(static type => type != AIDeploymentType.None)
.Select(static t => new SelectListItem(t.ToString(), t.ToString()))
.ToList();
+ }
- return Task.CompletedTask;
+ private async Task ValidateUniqueNameAsync(string technicalName, string currentItemId = null)
+ {
+ if (string.IsNullOrWhiteSpace(technicalName))
+ {
+ return;
+ }
+
+ var existing = await _deploymentCatalog.FindByNameAsync(technicalName);
+ if (existing != null && !string.Equals(existing.ItemId, currentItemId, StringComparison.OrdinalIgnoreCase))
+ {
+ ModelState.AddModelError(nameof(AIDeploymentViewModel.TechnicalName), "Technical name must be unique across appsettings and UI deployments.");
+ }
}
}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs
index e6a5db3f..4a87e56a 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs
@@ -23,6 +23,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers;
+
[Area("AI")]
[Authorize(Policy = "Admin")]
public sealed class AIProfileController : Controller
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs
index 4a91b424..c8c7354f 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs
@@ -23,6 +23,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers;
+
[Area("AI")]
[Authorize(Policy = "Admin")]
public sealed class AITemplateController : Controller
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs
new file mode 100644
index 00000000..55c79e6d
--- /dev/null
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs
@@ -0,0 +1,17 @@
+using CrestApps.Core.AI.Models;
+using CrestApps.Core.Builders;
+
+namespace CrestApps.Core.Mvc.Web.Areas.AI.Services;
+
+public static class AIProviderOptionsBuilderExtensions
+{
+ public static CrestAppsAISuiteBuilder ConfigureProviderOptions(this CrestAppsAISuiteBuilder builder, IConfigurationSection configuration)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ builder.Services.Configure(configuration);
+
+ return builder;
+ }
+}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs
deleted file mode 100644
index 5b892736..00000000
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using CrestApps.Core.AI.Models;
-using CrestApps.Core.Builders;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Options;
-
-namespace CrestApps.Core.Mvc.Web.Areas.AI.Services;
-
-
-///
-/// Projects MVC-managed AI provider connections into
-/// so framework AI clients can resolve connection settings from the sample app's
-/// YesSql-backed admin UI.
-///
-public sealed class MvcAIProviderOptionsConfiguration : IConfigureOptions
-{
- private readonly MvcAIProviderOptionsStore _providerOptionsStore;
-
- public MvcAIProviderOptionsConfiguration(
- MvcAIProviderOptionsStore providerOptionsStore)
- {
- _providerOptionsStore = providerOptionsStore;
- }
-
- public void Configure(AIProviderOptions options)
- {
- _providerOptionsStore.ApplyTo(options);
- }
-}
-
-public static class MvcAIProviderOptionsBuilderExtensions
-{
- public static CrestAppsAISuiteBuilder ConfigureProviderOptions(this CrestAppsAISuiteBuilder builder, IConfigurationSection configuration)
- {
- ArgumentNullException.ThrowIfNull(builder);
- ArgumentNullException.ThrowIfNull(configuration);
-
- builder.Services.Configure(configuration);
-
- return builder;
- }
-
- public static CrestAppsAISuiteBuilder AddMvcProviderOptions(this CrestAppsAISuiteBuilder builder)
- {
- ArgumentNullException.ThrowIfNull(builder);
-
- builder.Services.AddSingleton();
- builder.Services.AddTransient, MvcAIProviderOptionsConfiguration>();
-
- return builder;
- }
-}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs
deleted file mode 100644
index 5c0cd4fe..00000000
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using CrestApps.Core.AI.Models;
-using CrestApps.Core.AI.Services;
-using CrestApps.Core.Infrastructure;
-
-namespace CrestApps.Core.Mvc.Web.Areas.AI.Services;
-
-
-///
-/// Holds the MVC sample's runtime projection of stored AI provider connections.
-/// The snapshot is loaded during startup and refreshed after connection changes
-/// so can be rebuilt without querying YesSql
-/// inside the options pipeline.
-///
-public sealed class MvcAIProviderOptionsStore
-{
- private readonly object _syncLock = new();
-
- private Dictionary _providers = new(StringComparer.OrdinalIgnoreCase);
-
- public void Replace(IEnumerable connections)
- {
- var providers = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- foreach (var group in connections.GroupBy(static connection => AIProviderNameNormalizer.Normalize(connection.ClientName)))
- {
- if (string.IsNullOrWhiteSpace(group.Key))
- {
- continue;
- }
-
- var provider = new AIProvider
- {
- Connections = new Dictionary(StringComparer.OrdinalIgnoreCase),
- };
-
- foreach (var connection in group)
- {
- if (string.IsNullOrWhiteSpace(connection.Name))
- {
- continue;
- }
-
- var values = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- if (connection.Properties is not null)
- {
- foreach (var property in connection.Properties)
- {
- values[property.Key] = property.Value;
- }
- }
-
- values["ConnectionNameAlias"] = connection.Name;
-
- provider.Connections[connection.Name] = new AIProviderConnectionEntry(values);
- }
-
- if (provider.Connections.Count == 0)
- {
- continue;
- }
-
- providers[group.Key] = provider;
- }
-
- lock (_syncLock)
- {
- _providers = providers;
- }
- }
-
- public void ApplyTo(AIProviderOptions options)
- {
- Dictionary providers;
-
- lock (_syncLock)
- {
- providers = new Dictionary(_providers, StringComparer.OrdinalIgnoreCase);
- }
-
- foreach (var provider in providers)
- {
- var targetProvider = AIProviderOptionsConnectionMerger.GetOrAddProvider(options, provider.Key);
-
- foreach (var connection in provider.Value.Connections)
- {
- AIProviderOptionsConnectionMerger.MergeConnection(targetProvider, connection.Key, connection.Value);
- }
- }
- }
-}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs
deleted file mode 100644
index 6ccfc973..00000000
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using CrestApps.Core.AI.Deployments;
-using CrestApps.Core.AI.Models;
-using CrestApps.Core.Data.YesSql.Indexes.AI;
-using CrestApps.Core.Data.YesSql.Services;
-
-namespace CrestApps.Core.Mvc.Web.Areas.AI.Services;
-
-public sealed class YesSqlAIDeploymentStore : NamedSourceDocumentCatalog, IAIDeploymentStore
-{
- public YesSqlAIDeploymentStore(YesSql.ISession session)
- : base(session)
- {
- }
-}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs
index 536d56da..0dfccac1 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs
@@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Mvc.Rendering;
namespace CrestApps.Core.Mvc.Web.Areas.AI.ViewModels;
+
public sealed class AIConnectionViewModel
{
public string ItemId { get; set; }
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs
index f8e6ba9d..eee56096 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs
@@ -69,7 +69,6 @@ public void ApplyTo(AIDeployment deployment)
deployment.Type = GetDeploymentType();
deployment.ConnectionName = ConnectionName;
deployment.ClientName = AIProviderNameNormalizer.Normalize(ClientName);
- deployment.IsDefault = false;
deployment.Properties ??= new Dictionary();
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs
index 927c71e6..dc5aeb3e 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs
@@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Mvc.Rendering;
namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers;
+
[Area("AIChat")]
[Authorize(Policy = "Admin")]
public sealed class ChatExtractedDataController : Controller
@@ -68,7 +69,7 @@ public async Task Export(ChatExtractedDataIndexViewModel model)
private async Task BuildViewModelAsync(ChatExtractedDataIndexViewModel model, bool showReport)
{
var profiles = await _profileManager.GetAsync(AIProfileType.Chat);
- model.Profiles = [new SelectListItem("Select a profile", string.Empty, string.IsNullOrEmpty(model.ProfileId)), ..profiles.OrderBy(profile => profile.DisplayText ?? profile.Name, StringComparer.OrdinalIgnoreCase).Select(profile => new SelectListItem(profile.DisplayText ?? profile.Name, profile.ItemId, profile.ItemId == model.ProfileId)), ];
+ model.Profiles = [new SelectListItem("Select a profile", string.Empty, string.IsNullOrEmpty(model.ProfileId)), .. profiles.OrderBy(profile => profile.DisplayText ?? profile.Name, StringComparer.OrdinalIgnoreCase).Select(profile => new SelectListItem(profile.DisplayText ?? profile.Name, profile.ItemId, profile.ItemId == model.ProfileId)),];
model.ShowReport = showReport;
return model;
}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs
index cfb3c7d7..7b810a4d 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs
@@ -6,6 +6,7 @@
using Microsoft.Extensions.Options;
namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers;
+
[Area("AIChat")]
[Authorize(Policy = "Admin")]
public sealed class UsageAnalyticsController : Controller
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs
index cc5ff727..59d88d01 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs
@@ -2,6 +2,7 @@
using CrestApps.Core.AI.Models;
namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services;
+
public sealed class MvcAIChatSessionEventPostCloseObserver : IAIChatSessionAnalyticsRecorder, IAIChatSessionConversionGoalRecorder
{
private readonly MvcAIChatSessionEventService _eventService;
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs
index ce795728..9806fa56 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs
@@ -4,6 +4,7 @@
using ISession = YesSql.ISession;
namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services;
+
public sealed class MvcAIChatSessionEventService
{
private readonly ISession _session;
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs
index 8d6ebfcf..69f7fd5c 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs
@@ -5,6 +5,7 @@
using ISession = YesSql.ISession;
namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services;
+
public sealed class MvcAIChatSessionExtractedDataService : IAIChatSessionExtractedDataRecorder
{
private readonly ISession _session;
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs
index 00f24d94..23a9a8af 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs
@@ -394,12 +394,12 @@ private static IEnumerable BuildGroupedDeploymentItems(IEnumerab
var groups = new Dictionary(StringComparer.OrdinalIgnoreCase);
return deployments
- .OrderBy(d => d.ConnectionNameAlias ?? d.ConnectionName, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(d => d.ConnectionName, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
.Select(d =>
{
SelectListGroup group = null;
- var groupKey = d.ConnectionNameAlias ?? d.ConnectionName;
+ var groupKey = d.ConnectionName;
if (!string.IsNullOrEmpty(groupKey) && !groups.TryGetValue(groupKey, out group))
{
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs
index bf1006d2..33d1f4d7 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs
@@ -5,6 +5,7 @@
using CrestApps.Core.Mvc.Web.Areas.Admin.Services;
namespace CrestApps.Core.Mvc.Web.Areas.Admin.Handlers;
+
internal sealed class ArticleIndexProfileHandler : IndexProfileHandlerBase
{
private readonly ArticleIndexingService _indexingService;
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs
index 1597c524..b3252390 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs
@@ -18,6 +18,7 @@
#pragma warning disable MEAI001 // Speech APIs from Microsoft.Extensions.AI are preview and require explicit opt-in at each usage site.
namespace CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Hubs;
+
[Authorize]
public sealed class ChatInteractionHub : ChatInteractionHubBase
{
@@ -250,7 +251,7 @@ private async Task RunConversationLoopAsync(string itemId, IAsyncEnumerable _channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false, });
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs
index ca4aec8e..fc73228d 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs
@@ -1,9 +1,10 @@
using CrestApps.Core.Infrastructure.Indexing;
namespace CrestApps.Core.Mvc.Web.Areas.Indexing.Services;
+
public static class IndexProfileTypeRules
{
- public static readonly string[] EmbeddingTypes = [IndexProfileTypes.AIDocuments, IndexProfileTypes.AIMemory, IndexProfileTypes.DataSource, ];
+ public static readonly string[] EmbeddingTypes = [IndexProfileTypes.AIDocuments, IndexProfileTypes.AIMemory, IndexProfileTypes.DataSource,];
public static bool RequiresEmbedding(string type)
{
return EmbeddingTypes.Contains(type, StringComparer.OrdinalIgnoreCase);
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs
index 08bb53f8..0797765a 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs
@@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Mvc;
namespace CrestApps.Core.Mvc.Web.Areas.Mcp.Controllers;
+
[Area("Mcp")]
[Authorize(Policy = "Admin")]
public sealed class McpConnectionController : Controller
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs
index b3c3605d..a63ac9af 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs
@@ -7,6 +7,7 @@
using ModelContextProtocol.Protocol;
namespace CrestApps.Core.Mvc.Web.Areas.Mcp.Controllers;
+
[Area("Mcp")]
[Authorize(Policy = "Admin")]
public sealed class McpPromptController : Controller
@@ -110,7 +111,7 @@ private List ParseArguments(McpPromptViewModel model)
if (string.IsNullOrWhiteSpace(model.Arguments))
{
- return[];
+ return [];
}
try
@@ -129,7 +130,7 @@ private List ParseArguments(McpPromptViewModel model)
catch (JsonException)
{
ModelState.AddModelError(nameof(model.Arguments), "Arguments must be valid JSON.");
- return[];
+ return [];
}
}
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs
index 33911d4f..9c20defd 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs
@@ -12,6 +12,7 @@
using ModelContextProtocol.Protocol;
namespace CrestApps.Core.Mvc.Web.Areas.Mcp.Controllers;
+
[Area("Mcp")]
[Authorize(Policy = "Admin")]
public sealed class McpResourceController : Controller
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs
index 5a835105..7044a62f 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs
@@ -9,7 +9,6 @@
using CrestApps.Core.AI.Markdown;
using CrestApps.Core.AI.Mcp;
using CrestApps.Core.AI.Mcp.Models;
-using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Ollama;
using CrestApps.Core.AI.OpenAI;
using CrestApps.Core.AI.OpenAI.Azure;
@@ -32,12 +31,10 @@
using CrestApps.Core.Mvc.Web.Areas.DataSources.Services;
using CrestApps.Core.Mvc.Web.Services;
using CrestApps.Core.Mvc.Web.Tools;
-using CrestApps.Core.Services;
using CrestApps.Core.SignalR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Extensions.Options;
using NLog.Web;
// =============================================================================
@@ -128,7 +125,6 @@
builder.Services.AddCrestAppsCore(crestApps => crestApps
.AddAISuite(ai => ai
.ConfigureProviderOptions(builder.Configuration.GetSection("CrestApps:AI:Providers"))
- .AddMvcProviderOptions()
// Optional AI features layered on top of the core AI + orchestration runtime.
.AddMarkdown()
.AddCopilotOrchestrator()
@@ -288,14 +284,6 @@
// Seed sample articles on first run.
await app.Services.SeedArticlesAsync();
-using (var scope = app.Services.CreateScope())
-{
- var providerConnections = await scope.ServiceProvider.GetRequiredService>().GetAllAsync();
- app.Services.GetRequiredService().Replace(providerConnections);
-}
-
-app.Services.GetRequiredService>().TryRemove(Options.DefaultName);
-_ = app.Services.GetRequiredService>().Value;
// =============================================================================
// 13. MIDDLEWARE PIPELINE
// =============================================================================
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs
index 8a012afa..6ff51695 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs
@@ -55,13 +55,13 @@ private async Task ReadRootAsync()
{
if (!File.Exists(FilePath))
{
- return[];
+ return [];
}
var json = await File.ReadAllTextAsync(FilePath);
if (string.IsNullOrWhiteSpace(json))
{
- return[];
+ return [];
}
return JsonNode.Parse(json) as JsonObject ?? [];
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs
index 72635f88..412ee1fa 100644
--- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs
+++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs
@@ -19,6 +19,7 @@
using CrestApps.Core.Data.YesSql.Indexes.ChatInteractions;
using CrestApps.Core.Data.YesSql.Indexes.DataSources;
using CrestApps.Core.Data.YesSql.Indexes.Indexing;
+using CrestApps.Core.Data.YesSql.Services;
using CrestApps.Core.Infrastructure.Indexing;
using CrestApps.Core.Mvc.Web.Areas.A2A.Indexes;
using CrestApps.Core.Mvc.Web.Areas.Admin.Handlers;
@@ -65,8 +66,57 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection
Data.YesSql.ServiceCollectionExtensions.AddCoreYesSqlDataStore(services, configuration => configuration.UseSqLite(connectionStringBuilder.ToString()).SetTablePrefix("CA_"));
// YesSql-backed catalogs and managers.
- services.AddNamedSourceDocumentCatalog().AddNamedSourceDocumentCatalog().AddDocumentCatalog