diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0b73e387..12148c55 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,12 +72,18 @@ Keep the docs focused on `CrestApps.Core`. If you need to mention the Orchard Co - Add a blank line before and after `if` blocks, `switch` statements, and loops unless the block is immediately preceded by `{` - Do not add a blank line between an `if`/`else`/`switch`/loop condition and its opening `{` - Use `var` consistently with repository style +- Do not use `global using` files; add explicit `using` directives at the top of each file instead. +- Prefer top-of-file `using` directives over fully qualified type names in code. - Only use expression-bodied members when the entire member fits on a single short line; use a full block body for anything longer or split across lines - Avoid `DateTime.UtcNow`; prefer injected `TimeProvider`. - Keep public docs and comments honest to the current code. - Always document new interfaces, their methods and arguments along with documenting every property on domain models using `` block. - Always treat warnings are errors in the solutions and ensure every warning is addressed. - Always learn from my prompts, preference and styles and update the `copilot-instructions.md` file with any new preferences that I share in the future. +- Prefer SOLID and DRY refactors that consolidate duplicated provider, transport, or store logic into shared abstractions before adding new one-off implementations. +- Favor additive shared infrastructure first, then migrate consumers in behavior-safe steps when a full replacement is too risky for a single change. +- When working in framework code meant for external adoption, optimize for consistency and long-term maintainability across providers and hosts, not just local fixes. +- For optional provider integrations in sample hosts, do not eagerly read validated options in UI setup paths when an unconfigured provider should simply appear unavailable rather than crash the page. ## Runtime notes diff --git a/Directory.Packages.props b/Directory.Packages.props index 6e4e4e2a..b1e6a080 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,17 +4,16 @@ true 1.2.0 - - + - + @@ -31,13 +30,13 @@ - + @@ -49,29 +48,26 @@ - + - - + - - @@ -79,9 +75,8 @@ - - + \ No newline at end of file diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionHandler.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionHandler.cs index da7a766b..403185af 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionHandler.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionHandler.cs @@ -22,5 +22,6 @@ public interface IAIChatSessionHandler : ICatalogEntryHandler /// profile, session, messages, and an scoped /// to the current request. /// - Task MessageCompletedAsync(ChatMessageCompletedContext context); + /// The token to monitor for cancellation requests. + Task MessageCompletedAsync(ChatMessageCompletedContext context, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionManager.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionManager.cs index 9e598f6e..8771a649 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionManager.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IAIChatSessionManager.cs @@ -12,21 +12,23 @@ public interface IAIChatSessionManager /// Asynchronously retrieves an existing AI chat session by its session ID. /// /// The unique identifier of the chat session. + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. The task result is the if found, /// or null if no session with the specified ID exists. /// - Task FindByIdAsync(string id); + Task FindByIdAsync(string id, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves an existing AI chat session by its session ID after applying ownership check. /// /// The unique identifier of the chat session. Must not be null or empty. + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. The task result is the if found, /// or null if no session with the specified session ID exists. /// - Task FindAsync(string id); + Task FindAsync(string id, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves a list of top AI chat sessions based on the provided pagination parameters and query context. @@ -34,48 +36,53 @@ public interface IAIChatSessionManager /// The page number to retrieve (1-based index). Must be greater than 0. /// The number of sessions to retrieve per page. Must be greater than 0. /// The context used to filter and order the chat sessions. Must not be null. + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. The task result is a list of objects, /// which represent the top sessions based on the query context and pagination parameters. /// - Task PageAsync(int page, int pageSize, AIChatSessionQueryContext context = null); + Task PageAsync(int page, int pageSize, AIChatSessionQueryContext context = null, CancellationToken cancellationToken = default); /// /// Asynchronously creates a new AI chat session for the specified AI chat profile. /// /// The AI chat profile for which the new session will be created. Must not be null. /// The request context + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. The task result is a new /// associated with the provided profile. /// - Task NewAsync(AIProfile profile, NewAIChatSessionContext context); + Task NewAsync(AIProfile profile, NewAIChatSessionContext context, CancellationToken cancellationToken = default); /// /// Asynchronously saves or updates the specified AI chat session. /// /// The AI chat session to save or update. Must not be null. + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. This method does not return any value. /// - Task SaveAsync(AIChatSession chatSession); + Task SaveAsync(AIChatSession chatSession, CancellationToken cancellationToken = default); /// /// Asynchronously deletes the specified AI chat session. /// /// The unique identifier of the chat session to delete. Must not be null or empty. + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. The task result is true if the session was successfully deleted, /// or false if the session was not found or could not be deleted. /// - Task DeleteAsync(string sessionId); + Task DeleteAsync(string sessionId, CancellationToken cancellationToken = default); /// /// Asynchronously deletes all AI chat sessions for the specified profile and current user. /// /// The profile identifier to filter sessions. Must not be null or empty. + /// The token to monitor for cancellation requests. /// /// A task representing the asynchronous operation. The task result is the number of sessions that were deleted. /// - Task DeleteAllAsync(string profileId); + Task DeleteAllAsync(string profileId, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IChatInteractionSettingsHandler.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IChatInteractionSettingsHandler.cs index b8111eae..1d2c9254 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IChatInteractionSettingsHandler.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Chat/IChatInteractionSettingsHandler.cs @@ -20,12 +20,14 @@ public interface IChatInteractionSettingsHandler /// /// The being updated. /// The raw settings payload from the client. - Task UpdatingAsync(ChatInteraction interaction, JsonElement settings); + /// The token to monitor for cancellation requests. + Task UpdatingAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default); /// /// Called after the has been persisted. /// /// The updated . /// The raw settings payload from the client. - Task UpdatedAsync(ChatInteraction interaction, JsonElement settings); + /// The token to monitor for cancellation requests. + Task UpdatedAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionContextBuilder.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionContextBuilder.cs index 0e987706..6f52b82d 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionContextBuilder.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionContextBuilder.cs @@ -19,10 +19,11 @@ public interface IAICompletionContextBuilder /// /// The resource object (e.g., or ChatInteraction) used to seed and configure the completion context. Must not be . /// An optional delegate to override or fine-tune the context after handlers have run BuildingAsync but before BuiltAsync. + /// The token to monitor for cancellation requests. /// A task that completes with the fully built . /// Thrown if is . /// /// /// - ValueTask BuildAsync(object resource, Action configure = null); + ValueTask BuildAsync(object resource, Action configure = null, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionHandler.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionHandler.cs index 01a3521b..f479b102 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionHandler.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionHandler.cs @@ -13,13 +13,15 @@ public interface IAICompletionHandler /// Handles a received message asynchronously. /// /// The context containing details of the received message. + /// The token to monitor for cancellation requests. /// A task that represents the asynchronous operation. - Task ReceivedMessageAsync(ReceivedMessageContext context); + Task ReceivedMessageAsync(ReceivedMessageContext context, CancellationToken cancellationToken = default); /// /// Handles a received update asynchronously. /// /// The context containing details of the received update. + /// The token to monitor for cancellation requests. /// A task that represents the asynchronous operation. - Task ReceivedUpdateAsync(ReceivedUpdateContext context); + Task ReceivedUpdateAsync(ReceivedUpdateContext context, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionServiceHandler.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionServiceHandler.cs index 6075655b..def851de 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionServiceHandler.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/IAICompletionServiceHandler.cs @@ -16,6 +16,7 @@ public interface IAICompletionServiceHandler /// /// The that provides access to request-specific options and settings. /// + /// The token to monitor for cancellation requests. /// A task that represents the asynchronous operation. - Task ConfigureAsync(CompletionServiceConfigureContext context); + Task ConfigureAsync(CompletionServiceConfigureContext context, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentManager.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentManager.cs index 5474d8eb..7869511e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentManager.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentManager.cs @@ -14,21 +14,23 @@ public interface IAIDeploymentManager : INamedSourceCatalogManager /// Asynchronously retrieves a list of model deployments for the specified client. /// /// The name of the client. Must not be null or empty. + /// The token to monitor for cancellation requests. /// /// A ValueTask that represents the asynchronous operation. The result is an /// containing the model deployments for the specified client. /// - ValueTask> GetAllAsync(string clientName); + ValueTask> GetAllAsync(string clientName, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves all deployments supporting the specified type. /// /// The deployment type to filter by. + /// The token to monitor for cancellation requests. /// /// A ValueTask that represents the asynchronous operation. The result is an /// containing all deployments matching the specified type. /// - ValueTask> GetByTypeAsync(AIDeploymentType type); + ValueTask> GetByTypeAsync(AIDeploymentType type, CancellationToken cancellationToken = default); /// /// Resolves the default deployment of a given type for a specific client. @@ -37,7 +39,8 @@ public interface IAIDeploymentManager : INamedSourceCatalogManager /// /// The name of the client to resolve the default deployment for. /// The deployment type to filter by. - ValueTask GetDefaultAsync(string clientName, AIDeploymentType type); + /// The token to monitor for cancellation requests. + ValueTask GetDefaultAsync(string clientName, AIDeploymentType type, CancellationToken cancellationToken = default); /// /// Resolves a deployment using the full fallback chain: @@ -49,7 +52,8 @@ public interface IAIDeploymentManager : INamedSourceCatalogManager /// The deployment type to resolve. /// The optional deployment name to look up directly. /// The optional client name to scope the resolution. - ValueTask ResolveOrDefaultAsync(AIDeploymentType type, string deploymentName = null, string clientName = null); + /// The token to monitor for cancellation requests. + ValueTask ResolveOrDefaultAsync(AIDeploymentType type, string deploymentName = null, string clientName = null, CancellationToken cancellationToken = default); /// /// Gets all deployments of a given type, optionally filtered by client. @@ -57,5 +61,6 @@ public interface IAIDeploymentManager : INamedSourceCatalogManager /// /// The deployment type to filter by. /// The optional client name to further filter results. - ValueTask> GetAllByTypeAsync(AIDeploymentType type, string clientName = null); + /// The token to monitor for cancellation requests. + ValueTask> GetAllByTypeAsync(AIDeploymentType type, string clientName = null, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/NoRegisteredCompletionClient.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/NoRegisteredCompletionClient.cs deleted file mode 100644 index 82b87770..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/NoRegisteredCompletionClient.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CrestApps.Core.AI.Exceptions; - -public class UnregisteredCompletionClientException : Exception -{ - public UnregisteredCompletionClientException(string clientName) - : base($"No registered completion client was found to match '{clientName}'.") - { - } -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/UnregisteredCompletionClientException.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/UnregisteredCompletionClientException.cs new file mode 100644 index 00000000..e81a1717 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/UnregisteredCompletionClientException.cs @@ -0,0 +1,9 @@ +namespace CrestApps.Core.AI.Exceptions; + +public sealed class UnregisteredCompletionClientException : Exception +{ + public UnregisteredCompletionClientException(string clientName) + : base($"No registered completion client was found to match '{clientName}'.") + { + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContextBuiltContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContextBuiltContext.cs index 3c026db7..98cf1eca 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContextBuiltContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContextBuiltContext.cs @@ -33,6 +33,11 @@ public AICompletionContextBuiltContext(object resource, AICompletionContext cont /// public object Resource { get; } + /// + /// Gets the resource as the specified type. + /// + public T GetResource() where T : class => Resource as T; + /// /// Gets the finalized . /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDataSource.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDataSource.cs index b78003ab..e11f46a5 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDataSource.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDataSource.cs @@ -5,10 +5,10 @@ namespace CrestApps.Core.AI.Models; public sealed class AIDataSource : CatalogItem, IDisplayTextAwareModel, ICloneable { - [Obsolete("Do no use any more.")] + [Obsolete("Do not use any more.")] public string ProfileSource { get; set; } - [Obsolete("Do no use any more.")] + [Obsolete("Do not use any more.")] public string Type { get; set; } public string DisplayText { get; set; } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileExtensions.cs index a32c3b01..e170a225 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileExtensions.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileExtensions.cs @@ -1,6 +1,5 @@ using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; namespace CrestApps.Core.AI.Models; @@ -9,22 +8,13 @@ namespace CrestApps.Core.AI.Models; /// public static class AIProfileExtensions { - private static readonly JsonSerializerOptions _ignoreDefaultValuesSerializer = new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - ReferenceHandler = ReferenceHandler.IgnoreCycles, - PropertyNameCaseInsensitive = true, - Converters = - { - new JsonStringEnumConverter() - } - }; + private static JsonSerializerOptions _jsonOptions => ExtensibleEntityExtensions.JsonSerializerOptions; /// /// Retrieves settings of type from the profile. /// If the settings do not exist, a new instance of is returned. /// - public static T GetSettings(this AIProfile profile) + public static T GetSettings(this AIProfile profile, JsonSerializerOptions jsonSerializerOptions = null) where T : new() { if (profile.Settings == null) @@ -39,13 +29,13 @@ public static T GetSettings(this AIProfile profile) return new T(); } - return node.Deserialize(_ignoreDefaultValuesSerializer) ?? new T(); + return node.Deserialize(jsonSerializerOptions ?? _jsonOptions) ?? new T(); } /// /// Attempts to retrieve settings of type from the profile. /// - public static bool TryGetSettings(this AIProfile profile, out T settings) + public static bool TryGetSettings(this AIProfile profile, out T settings, JsonSerializerOptions jsonSerializerOptions = null) where T : class { if (profile.Settings == null) @@ -59,10 +49,11 @@ public static bool TryGetSettings(this AIProfile profile, out T settings) if (node == null) { settings = null; + return false; } - settings = node.Deserialize(_ignoreDefaultValuesSerializer); + settings = node.Deserialize(jsonSerializerOptions ?? _jsonOptions); return true; } @@ -70,22 +61,23 @@ public static bool TryGetSettings(this AIProfile profile, out T settings) /// /// Alters existing settings or adds new settings of type if one does not exists. /// - public static AIProfile AlterSettings(this AIProfile profile, Action setting) + public static AIProfile AlterSettings(this AIProfile profile, Action setting, JsonSerializerOptions jsonSerializerOptions = null) where T : class, new() { var existingJObject = profile.Settings[typeof(T).Name] as JsonObject; if (existingJObject == null) { - existingJObject = JsonExtensions.FromObject(new T(), _ignoreDefaultValuesSerializer); + existingJObject = JsonExtensions.FromObject(new T(), jsonSerializerOptions ?? _jsonOptions); + profile.Settings[typeof(T).Name] = existingJObject; } - var settingsToMerge = existingJObject.Deserialize(_ignoreDefaultValuesSerializer); + var settingsToMerge = existingJObject.Deserialize(jsonSerializerOptions ?? _jsonOptions); setting(settingsToMerge); - profile.Settings[typeof(T).Name] = JsonExtensions.FromObject(settingsToMerge, _ignoreDefaultValuesSerializer); + profile.Settings[typeof(T).Name] = JsonExtensions.FromObject(settingsToMerge, jsonSerializerOptions ?? _jsonOptions); return profile; } @@ -93,11 +85,11 @@ public static AIProfile AlterSettings(this AIProfile profile, Action setti /// /// Sets or replaces the settings of type in the profile. /// - public static AIProfile WithSettings(this AIProfile profile, T settings) + public static AIProfile WithSettings(this AIProfile profile, T settings, JsonSerializerOptions jsonSerializerOptions = null) { ArgumentNullException.ThrowIfNull(settings); - var jObject = JsonExtensions.FromObject(settings, _ignoreDefaultValuesSerializer); + var jObject = JsonExtensions.FromObject(settings, jsonSerializerOptions ?? _jsonOptions); profile.Settings[typeof(T).Name] = jObject; diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileTemplate.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileTemplate.cs index 65d4e172..fa501a4e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileTemplate.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProfileTemplate.cs @@ -5,7 +5,7 @@ namespace CrestApps.Core.AI.Models; /// /// Represents a reusable template. The template holds only generic metadata; -/// source-specific data is stored in +/// source-specific data is stored in /// via metadata classes such as or /// . /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ChatNotificationActionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ChatNotificationActionContext.cs index 231cfd92..829d65db 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ChatNotificationActionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ChatNotificationActionContext.cs @@ -36,5 +36,9 @@ public sealed class ChatNotificationActionContext /// /// Gets the scoped service provider for resolving dependencies. /// + /// + /// Prefer constructor injection over resolving services from this provider. + /// This property is provided for extensibility scenarios where constructor injection is not available. + /// public required IServiceProvider Services { get; init; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs index cafa9654..35d27ebb 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs @@ -6,15 +6,13 @@ public sealed class CompletionServiceConfigureContext { public string ClientName { get; set; } - public string ImplemenationName { get; set; } - public string DeploymentName { get; set; } public bool IsStreaming { get; set; } public ChatOptions ChatOptions { get; } - public readonly AICompletionContext CompletionContext; + public AICompletionContext CompletionContext { get; } public bool IsFunctionInvocationSupported { get; } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExportingAIProviderConnectionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExportingAIProviderConnectionContext.cs index aecf1326..cbcb0f23 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExportingAIProviderConnectionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExportingAIProviderConnectionContext.cs @@ -4,9 +4,9 @@ namespace CrestApps.Core.AI.Models; public class ExportingAIProviderConnectionContext { - public readonly AIProviderConnection Connection; + public AIProviderConnection Connection { get; } - public readonly JsonObject ExportData; + public JsonObject ExportData { get; } public ExportingAIProviderConnectionContext(AIProviderConnection connection, JsonObject exportData) { diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExternalChatRelayEventType.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExternalChatRelayEventTypes.cs similarity index 100% rename from src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExternalChatRelayEventType.cs rename to src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ExternalChatRelayEventTypes.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/InitializingAIProviderConnectionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/InitializingAIProviderConnectionContext.cs index 27210cda..21c96ec0 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/InitializingAIProviderConnectionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/InitializingAIProviderConnectionContext.cs @@ -2,9 +2,9 @@ namespace CrestApps.Core.AI.Models; public class InitializingAIProviderConnectionContext { - public readonly Dictionary Values = []; + public Dictionary Values { get; } = []; - public readonly AIProviderConnection Connection; + public AIProviderConnection Connection { get; } public InitializingAIProviderConnectionContext(AIProviderConnection connection) { diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuildingContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuildingContext.cs index bf5a280f..100eb607 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuildingContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuildingContext.cs @@ -33,6 +33,11 @@ public OrchestrationContextBuildingContext(object resource, OrchestrationContext /// public object Resource { get; } + /// + /// Gets the resource as the specified type. + /// + public T GetResource() where T : class => Resource as T; + /// /// Gets the mutable being built. Handlers may mutate this instance. /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuiltContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuiltContext.cs index f59ef461..dd00a4e9 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuiltContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestrationContextBuiltContext.cs @@ -33,6 +33,11 @@ public OrchestrationContextBuiltContext(object resource, OrchestrationContext co /// public object Resource { get; } + /// + /// Gets the resource as the specified type. + /// + public T GetResource() where T : class => Resource as T; + /// /// Gets the finalized . /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestratorContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestratorContext.cs index 4e420079..e72e56fc 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestratorContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/OrchestratorContext.cs @@ -41,8 +41,11 @@ public sealed class OrchestrationContext /// /// Gets or sets the scoped service provider for this orchestration session. - /// Allows orchestrators to resolve services without constructor injection. /// + /// + /// Prefer constructor injection over resolving services from this provider. + /// This property is provided for extensibility scenarios where constructor injection is not available. + /// public IServiceProvider ServiceProvider { get; set; } /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs index 30ad17f7..fe0224cb 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs @@ -29,6 +29,11 @@ public PreemptiveRagContext(OrchestrationContext orchestrationContext, object re /// public object Resource { get; } + /// + /// Gets the resource as the specified type. + /// + public T GetResource() where T : class => Resource as T; + /// /// Gets the focused search queries extracted by the preemptive search query provider. /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ProfileTemplateMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ProfileTemplateMetadata.cs index 2d2ab4e9..e2f45989 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ProfileTemplateMetadata.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ProfileTemplateMetadata.cs @@ -5,7 +5,7 @@ namespace CrestApps.Core.AI.Models; /// /// Metadata for templates with a "Profile" source. -/// Stored in the template's via +/// Stored in the template's via /// Put<ProfileTemplateMetadata> / As<ProfileTemplateMetadata>. /// public sealed class ProfileTemplateMetadata diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/SystemPromptTemplateMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/SystemPromptTemplateMetadata.cs index 11e152a4..5422c5c8 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/SystemPromptTemplateMetadata.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/SystemPromptTemplateMetadata.cs @@ -2,7 +2,7 @@ namespace CrestApps.Core.AI.Models; /// /// Metadata for templates with a "SystemPrompt" source. -/// Stored in the template's via +/// Stored in the template's via /// Put<SystemPromptTemplateMetadata> / As<SystemPromptTemplateMetadata>. /// public sealed class SystemPromptTemplateMetadata diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileManager.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileManager.cs index 1343fec3..4b59019f 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileManager.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileManager.cs @@ -14,6 +14,7 @@ public interface IAIProfileManager : INamedCatalogManager /// Asynchronously retrieves a collection of AI chat profiles of the specified type. /// /// The type of AI chat profiles to retrieve. + /// The token to monitor for cancellation requests. /// A ValueTask that represents the asynchronous operation. The result is an enumerable collection of AIProfile objects matching the specified type. - ValueTask> GetAsync(AIProfileType type); + ValueTask> GetAsync(AIProfileType type, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateManager.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateManager.cs index 2fa904f5..16d0a3bb 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateManager.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateManager.cs @@ -13,5 +13,5 @@ public interface IAIProfileTemplateManager : INamedSourceCatalogManager - ValueTask> GetListableAsync(); + ValueTask> GetListableAsync(CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateProvider.cs index 5351abb5..744ae4f1 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateProvider.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Profiles/IAIProfileTemplateProvider.cs @@ -10,5 +10,5 @@ public interface IAIProfileTemplateProvider /// /// Gets all profile templates from this provider. /// - Task> GetTemplatesAsync(); + Task> GetTemplatesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/ResponseHandling/ChatResponseHandlerContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/ResponseHandling/ChatResponseHandlerContext.cs index 419dde79..dcc47c23 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/ResponseHandling/ChatResponseHandlerContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/ResponseHandling/ChatResponseHandlerContext.cs @@ -42,6 +42,10 @@ public sealed class ChatResponseHandlerContext /// /// Gets the scoped service provider for resolving services. /// + /// + /// Prefer constructor injection over resolving services from this provider. + /// This property is provided for extensibility scenarios where constructor injection is not available. + /// public required IServiceProvider Services { get; init; } /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolExecutionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolExecutionContext.cs index 5e88c047..0d6200d5 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolExecutionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Tooling/AIToolExecutionContext.cs @@ -23,6 +23,12 @@ public sealed class AIToolExecutionContext /// public object Resource { get; } + /// + /// Gets the resource as the specified type. + /// + public T GetResource() + where T : class => Resource as T; + public AIToolExecutionContext(object resource) { ArgumentNullException.ThrowIfNull(resource); diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs index 3b32236d..3b7c0877 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs @@ -35,7 +35,7 @@ public static JsonSerializerOptions JsonSerializerOptions /// /// Gets a strongly-typed object stored in the entity's properties. /// - public static T GetOrCreate(this ExtensibleEntity entity) + public static T GetOrCreate(this ExtensibleEntity entity, JsonSerializerOptions jsonSerializerOptions = null) where T : new() { ArgumentNullException.ThrowIfNull(entity); @@ -43,20 +43,20 @@ public static T GetOrCreate(this ExtensibleEntity entity) var key = typeof(T).Name; return entity.Properties.TryGetValue(key, out var value) - ? DeserializeValue(value) ?? new T() + ? DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions) ?? new T() : new T(); } /// /// Gets a strongly-typed object stored in the entity's properties, or null if not found. /// - public static T Get(this ExtensibleEntity entity, string name) + public static T Get(this ExtensibleEntity entity, string name, JsonSerializerOptions jsonSerializerOptions = null) { ArgumentNullException.ThrowIfNull(entity); ArgumentException.ThrowIfNullOrEmpty(name); return entity.Properties.TryGetValue(name, out var value) - ? DeserializeValue(value) + ? DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions) : default; } @@ -90,7 +90,7 @@ public static ExtensibleEntity Put(this ExtensibleEntity entity, string name, ob /// Tries to get a strongly-typed object stored in the entity's properties. /// Returns true if a non-null value was found and deserialized. /// - public static bool TryGet(this ExtensibleEntity entity, out T result) + public static bool TryGet(this ExtensibleEntity entity, out T result, JsonSerializerOptions jsonSerializerOptions = null) where T : class { ArgumentNullException.ThrowIfNull(entity); @@ -99,11 +99,13 @@ public static bool TryGet(this ExtensibleEntity entity, out T result) if (entity.Properties.TryGetValue(key, out var value) && value is not null) { - result = DeserializeValue(value); + result = DeserializeValue(value, jsonSerializerOptions ?? _jsonOptions); + return result is not null; } result = default; + return false; } @@ -121,14 +123,16 @@ public static bool Has(this ExtensibleEntity entity) /// Modifies a stored object in-place. If no object exists, a new instance is created, /// modified, and stored. /// - public static ExtensibleEntity Alter(this ExtensibleEntity entity, Action alter) + public static ExtensibleEntity Alter(this ExtensibleEntity entity, Action alter, JsonSerializerOptions jsonSerializerOptions = null) where T : new() { ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(alter); - var obj = entity.GetOrCreate(); + var obj = entity.GetOrCreate(jsonSerializerOptions ?? _jsonOptions); + alter(obj); + entity.Put(obj); return entity; @@ -146,7 +150,7 @@ public static ExtensibleEntity Remove(this ExtensibleEntity entity) return entity; } - private static T DeserializeValue(object value) + private static T DeserializeValue(object value, JsonSerializerOptions jsonSerializerOptions = null) { if (value is null) { @@ -165,15 +169,16 @@ private static T DeserializeValue(object value) return default; } - return jsonElement.Deserialize(_jsonOptions); + return jsonElement.Deserialize(jsonSerializerOptions ?? _jsonOptions); } if (value is JsonNode jsonNode) { - return jsonNode.Deserialize(_jsonOptions); + return jsonNode.Deserialize(jsonSerializerOptions ?? _jsonOptions); } - var json = JsonSerializer.Serialize(value, _jsonOptions); - return JsonSerializer.Deserialize(json, _jsonOptions); + var json = JsonSerializer.Serialize(value, jsonSerializerOptions ?? _jsonOptions); + + return JsonSerializer.Deserialize(json, jsonSerializerOptions ?? _jsonOptions); } } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/IDisplayTextAwareModel.cs b/src/Abstractions/CrestApps.Core.Abstractions/IDisplayTextAwareModel.cs index 288f70ef..8c6353fc 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/IDisplayTextAwareModel.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/IDisplayTextAwareModel.cs @@ -7,7 +7,7 @@ namespace CrestApps.Core; public interface IDisplayTextAwareModel { /// - /// Gets or sets the human-readable display text for this model. + /// Gets the human-readable display text for this model. /// - string DisplayText { get; set; } + string DisplayText { get; } } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/INameAwareModel.cs b/src/Abstractions/CrestApps.Core.Abstractions/INameAwareModel.cs index f78b296d..f8160a57 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/INameAwareModel.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/INameAwareModel.cs @@ -7,7 +7,7 @@ namespace CrestApps.Core; public interface INameAwareModel { /// - /// Gets or sets the unique technical name for this model. + /// Gets the unique technical name for this model. /// - string Name { get; set; } + string Name { get; } } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ISourceAwareModel.cs b/src/Abstractions/CrestApps.Core.Abstractions/ISourceAwareModel.cs index e614c623..07939fd1 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/ISourceAwareModel.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/ISourceAwareModel.cs @@ -9,5 +9,9 @@ public interface ISourceAwareModel /// /// Gets or sets the name of the source or provider that owns this model. /// + /// + /// The setter is retained because framework code assigns this property through + /// the interface type constraint (e.g., in SourceCatalogManager). + /// string Source { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalog.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalog.cs index be7b305c..f0b33936 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalog.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalog.cs @@ -11,19 +11,22 @@ public interface ICatalog : IReadCatalog /// Asynchronously deletes the specified entry from the catalog. /// /// The entry to delete. + /// The token to monitor for cancellation requests. /// if the entry was successfully deleted; otherwise, . - ValueTask DeleteAsync(T entry); + ValueTask DeleteAsync(T entry, CancellationToken cancellationToken = default); /// /// Asynchronously creates the specified entry in the catalog. /// /// The entry to create. - ValueTask CreateAsync(T entry); + /// The token to monitor for cancellation requests. + ValueTask CreateAsync(T entry, CancellationToken cancellationToken = default); /// /// Asynchronously updates the specified entry in the catalog. /// /// The entry to update. - ValueTask UpdateAsync(T entry); + /// The token to monitor for cancellation requests. + ValueTask UpdateAsync(T entry, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEntryHandler.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEntryHandler.cs index 95a93dd1..728e7be3 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEntryHandler.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEntryHandler.cs @@ -14,65 +14,76 @@ public interface ICatalogEntryHandler /// Called when a catalog entry is being initialized with default values. /// /// The context containing the entry being initialized. - Task InitializingAsync(InitializingContext context); + /// The token to monitor for cancellation requests. + Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default); /// /// Called after a catalog entry has been initialized with default values. /// /// The context containing the initialized entry. - Task InitializedAsync(InitializedContext context); + /// The token to monitor for cancellation requests. + Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default); /// /// Called after a catalog entry has been loaded from the store. /// /// The context containing the loaded entry. - Task LoadedAsync(LoadedContext context); + /// The token to monitor for cancellation requests. + Task LoadedAsync(LoadedContext context, CancellationToken cancellationToken = default); /// /// Called when a catalog entry is about to be validated. /// /// The context containing the entry to validate. - Task ValidatingAsync(ValidatingContext context); + /// The token to monitor for cancellation requests. + Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default); /// /// Called after a catalog entry has been validated. /// /// The context containing the validated entry and any validation results. - Task ValidatedAsync(ValidatedContext context); + /// The token to monitor for cancellation requests. + Task ValidatedAsync(ValidatedContext context, CancellationToken cancellationToken = default); /// /// Called when a catalog entry is about to be deleted. /// /// The context containing the entry to delete. - Task DeletingAsync(DeletingContext context); + /// The token to monitor for cancellation requests. + Task DeletingAsync(DeletingContext context, CancellationToken cancellationToken = default); /// /// Called after a catalog entry has been deleted. /// /// The context containing the deleted entry. - Task DeletedAsync(DeletedContext context); + /// The token to monitor for cancellation requests. + Task DeletedAsync(DeletedContext context, CancellationToken cancellationToken = default); /// /// Called when a catalog entry is about to be updated. /// /// The context containing the entry to update. - Task UpdatingAsync(UpdatingContext context); + /// The token to monitor for cancellation requests. + Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default); /// /// Called after a catalog entry has been updated. /// /// The context containing the updated entry. - Task UpdatedAsync(UpdatedContext context); + /// The token to monitor for cancellation requests. + Task UpdatedAsync(UpdatedContext context, CancellationToken cancellationToken = default); /// /// Called when a catalog entry is about to be created. /// /// The context containing the entry to create. - Task CreatingAsync(CreatingContext context); + /// The token to monitor for cancellation requests. + Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default); /// /// Called after a catalog entry has been created. /// /// The context containing the created entry. - Task CreatedAsync(CreatedContext context); + /// The token to monitor for cancellation requests. + Task CreatedAsync(CreatedContext context, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEventHandler.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEventHandler.cs new file mode 100644 index 00000000..d4939b70 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogEventHandler.cs @@ -0,0 +1,115 @@ +using CrestApps.Core.Models; + +namespace CrestApps.Core.Services; + +/// +/// Handler invoked when a catalog entry is being created. +/// +/// The type of catalog entry. +public interface ICatalogCreatingHandler where T : class +{ + /// + /// Called when a catalog entry is about to be created. + /// + /// The context containing the entry to create. + /// The token to monitor for cancellation requests. + Task CreatingAsync(CreatingContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked after a catalog entry has been created. +/// +/// The type of catalog entry. +public interface ICatalogCreatedHandler where T : class +{ + /// + /// Called after a catalog entry has been created. + /// + /// The context containing the created entry. + /// The token to monitor for cancellation requests. + Task CreatedAsync(CreatedContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked when a catalog entry is being updated. +/// +/// The type of catalog entry. +public interface ICatalogUpdatingHandler where T : class +{ + /// + /// Called when a catalog entry is about to be updated. + /// + /// The context containing the entry to update. + /// The token to monitor for cancellation requests. + Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked after a catalog entry has been updated. +/// +/// The type of catalog entry. +public interface ICatalogUpdatedHandler where T : class +{ + /// + /// Called after a catalog entry has been updated. + /// + /// The context containing the updated entry. + /// The token to monitor for cancellation requests. + Task UpdatedAsync(UpdatedContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked when a catalog entry is being deleted. +/// +/// The type of catalog entry. +public interface ICatalogDeletingHandler where T : class +{ + /// + /// Called when a catalog entry is about to be deleted. + /// + /// The context containing the entry to delete. + /// The token to monitor for cancellation requests. + Task DeletingAsync(DeletingContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked after a catalog entry has been deleted. +/// +/// The type of catalog entry. +public interface ICatalogDeletedHandler where T : class +{ + /// + /// Called after a catalog entry has been deleted. + /// + /// The context containing the deleted entry. + /// The token to monitor for cancellation requests. + Task DeletedAsync(DeletedContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked when a catalog entry is being validated. +/// +/// The type of catalog entry. +public interface ICatalogValidatingHandler where T : class +{ + /// + /// Called when a catalog entry is about to be validated. + /// + /// The context containing the entry to validate. + /// The token to monitor for cancellation requests. + Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default); +} + +/// +/// Handler invoked after a catalog entry has been validated. +/// +/// The type of catalog entry. +public interface ICatalogValidatedHandler where T : class +{ + /// + /// Called after a catalog entry has been validated. + /// + /// The context containing the validated entry and any validation results. + /// The token to monitor for cancellation requests. + Task ValidatedAsync(ValidatedContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogManager.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogManager.cs index 7fb6a065..e98dfd40 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogManager.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/ICatalogManager.cs @@ -15,33 +15,38 @@ public interface ICatalogManager : IReadCatalogManager /// Asynchronously deletes the specified model from the catalog. /// /// The model to delete. + /// The token to monitor for cancellation requests. /// if the model was successfully deleted; otherwise, . - ValueTask DeleteAsync(T model); + ValueTask DeleteAsync(T model, CancellationToken cancellationToken = default); /// /// Asynchronously creates a new model instance, optionally populating it from JSON data. /// /// Optional JSON data to seed the new model. + /// The token to monitor for cancellation requests. /// A newly created and initialized model instance. - ValueTask NewAsync(JsonNode data = null); + ValueTask NewAsync(JsonNode data = null, CancellationToken cancellationToken = default); /// /// Asynchronously creates the specified model in the catalog. /// /// The model to create. - ValueTask CreateAsync(T model); + /// The token to monitor for cancellation requests. + ValueTask CreateAsync(T model, CancellationToken cancellationToken = default); /// /// Asynchronously updates the specified model in the catalog, optionally merging changes from JSON data. /// /// The model to update. /// Optional JSON data containing fields to merge into the model. - ValueTask UpdateAsync(T model, JsonNode data = null); + /// The token to monitor for cancellation requests. + ValueTask UpdateAsync(T model, JsonNode data = null, CancellationToken cancellationToken = default); /// /// Asynchronously validates the specified model and returns the validation result. /// /// The model to validate. + /// The token to monitor for cancellation requests. /// The validation result details indicating success or failure with error messages. - ValueTask ValidateAsync(T model); + ValueTask ValidateAsync(T model, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalog.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalog.cs index d9163763..7f6b9cc6 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalog.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalog.cs @@ -13,6 +13,7 @@ public interface INamedCatalog : ICatalog /// Asynchronously finds a catalog entry by its unique name. /// /// The unique name of the entry to find. + /// The token to monitor for cancellation requests. /// The matching entry, or if no entry with the specified name exists. - ValueTask FindByNameAsync(string name); + ValueTask FindByNameAsync(string name, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogManager.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogManager.cs index 4f80f9e5..e9ef0015 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogManager.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogManager.cs @@ -13,6 +13,7 @@ public interface INamedCatalogManager : ICatalogManager /// Asynchronously finds a catalog entry by its unique name. /// /// The unique name of the entry to find. + /// The token to monitor for cancellation requests. /// The matching entry, or if no entry with the specified name exists. - ValueTask FindByNameAsync(string name); + ValueTask FindByNameAsync(string name, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs index 94c4bcd9..87b9735b 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs @@ -22,6 +22,7 @@ public interface INamedCatalogSource /// Entries already collected from higher-priority sources, allowing this source /// to skip entries whose names conflict with existing ones. /// + /// The token to monitor for cancellation requests. /// A read-only collection of entries from this source. - ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries); + ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalog.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalog.cs index 56278736..6ca7a1a1 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalog.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalog.cs @@ -14,6 +14,7 @@ public interface INamedSourceCatalog : INamedCatalog, ISourceCatalog /// /// The unique name of the entry. /// The source or provider name of the entry. + /// The token to monitor for cancellation requests. /// The matching entry, or if not found. - ValueTask GetAsync(string name, string source); + ValueTask GetAsync(string name, string source, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogManager.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogManager.cs index 276b06cd..d9b26c24 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogManager.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogManager.cs @@ -14,6 +14,7 @@ public interface INamedSourceCatalogManager : INamedCatalogManager, ISourc /// /// The unique name of the entry. /// The source or provider name of the entry. + /// The token to monitor for cancellation requests. /// The matching entry, or if not found. - ValueTask GetAsync(string name, string source); + ValueTask GetAsync(string name, string source, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalog.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalog.cs index 1f4652e5..4bdc15d3 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalog.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalog.cs @@ -13,21 +13,24 @@ public interface IReadCatalog /// Asynchronously finds a catalog entry by its unique identifier. /// /// The unique identifier of the entry. + /// The token to monitor for cancellation requests. /// The matching entry, or if not found. - ValueTask FindByIdAsync(string id); + ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves all entries in the catalog. /// + /// The token to monitor for cancellation requests. /// A read-only collection of all catalog entries. - ValueTask> GetAllAsync(); + ValueTask> GetAllAsync(CancellationToken cancellationToken = default); /// /// Asynchronously retrieves catalog entries matching the specified identifiers. /// /// The identifiers of the entries to retrieve. + /// The token to monitor for cancellation requests. /// A read-only collection of matching entries. - ValueTask> GetAsync(IEnumerable ids); + ValueTask> GetAsync(IEnumerable ids, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves a paginated subset of catalog entries using the specified query context. @@ -36,7 +39,8 @@ public interface IReadCatalog /// The one-based page number to retrieve. /// The number of entries per page. /// The query context used to filter and order results. + /// The token to monitor for cancellation requests. /// A page result containing the entries and total count. - ValueTask> PageAsync(int page, int pageSize, TQuery context) + ValueTask> PageAsync(int page, int pageSize, TQuery context, CancellationToken cancellationToken = default) where TQuery : QueryContext; } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalogManager.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalogManager.cs index 4a17ac9b..609ea408 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalogManager.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/IReadCatalogManager.cs @@ -13,14 +13,16 @@ public interface IReadCatalogManager /// Asynchronously finds a catalog entry by its unique identifier. /// /// The unique identifier of the entry. + /// The token to monitor for cancellation requests. /// The matching entry, or if not found. - ValueTask FindByIdAsync(string id); + ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves all entries in the catalog. /// + /// The token to monitor for cancellation requests. /// An enumerable of all catalog entries. - ValueTask> GetAllAsync(); + ValueTask> GetAllAsync(CancellationToken cancellationToken = default); /// /// Asynchronously retrieves a paginated subset of catalog entries using the specified query context. @@ -29,7 +31,8 @@ public interface IReadCatalogManager /// The one-based page number to retrieve. /// The number of entries per page. /// The query context used to filter and order results. + /// The token to monitor for cancellation requests. /// A page result containing the entries and total count. - ValueTask> PageAsync(int page, int pageSize, TQuery context) + ValueTask> PageAsync(int page, int pageSize, TQuery context, CancellationToken cancellationToken = default) where TQuery : QueryContext; } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalog.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalog.cs index c7dd8a50..426e2a6e 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalog.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalog.cs @@ -12,6 +12,7 @@ public interface ISourceCatalog : ICatalog /// Asynchronously retrieves all catalog entries belonging to the specified source. /// /// The source or provider name to filter by. + /// The token to monitor for cancellation requests. /// A read-only collection of entries matching the specified source. - ValueTask> GetAsync(string source); + ValueTask> GetAsync(string source, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalogManager.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalogManager.cs index 574d981e..a870c26f 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalogManager.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/ISourceCatalogManager.cs @@ -16,20 +16,23 @@ public interface ISourceCatalogManager : ICatalogManager /// /// The source or provider name to assign to the new model. /// Optional JSON data to seed the new model. + /// The token to monitor for cancellation requests. /// A newly created and initialized model instance assigned to the specified source. - ValueTask NewAsync(string source, JsonNode data = null); + ValueTask NewAsync(string source, JsonNode data = null, CancellationToken cancellationToken = default); /// /// Asynchronously retrieves all catalog entries belonging to the specified source. /// /// The source or provider name to filter by. + /// The token to monitor for cancellation requests. /// An enumerable of entries matching the specified source. - ValueTask> GetAsync(string source); + ValueTask> GetAsync(string source, CancellationToken cancellationToken = default); /// /// Asynchronously finds all catalog entries that belong to the specified source. /// /// The source or provider name to search for. + /// The token to monitor for cancellation requests. /// An enumerable of entries matching the specified source. - ValueTask> FindBySourceAsync(string source); + ValueTask> FindBySourceAsync(string source, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs index 93029f64..96a3fb5c 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs @@ -13,18 +13,21 @@ public interface IWritableNamedCatalogSource : INamedCatalogSource /// Asynchronously deletes the specified entry from this source. /// /// The entry to delete. + /// The token to monitor for cancellation requests. /// if the entry was successfully deleted; otherwise, . - ValueTask DeleteAsync(T entry); + ValueTask DeleteAsync(T entry, CancellationToken cancellationToken = default); /// /// Asynchronously creates the specified entry in this source. /// /// The entry to create. - ValueTask CreateAsync(T entry); + /// The token to monitor for cancellation requests. + ValueTask CreateAsync(T entry, CancellationToken cancellationToken = default); /// /// Asynchronously updates the specified entry in this source. /// /// The entry to update. - ValueTask UpdateAsync(T entry); + /// The token to monitor for cancellation requests. + ValueTask UpdateAsync(T entry, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs index 0acd019c..09dabc8d 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs @@ -21,15 +21,15 @@ public WritableCatalogBindingSource(INamedSourceCatalog inner) /// public int Order => 0; - public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) - => _inner.GetAllAsync(); + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default) + => _inner.GetAllAsync(cancellationToken); - public ValueTask DeleteAsync(T entry) - => _inner.DeleteAsync(entry); + public ValueTask DeleteAsync(T entry, CancellationToken cancellationToken = default) + => _inner.DeleteAsync(entry, cancellationToken); - public ValueTask CreateAsync(T entry) - => _inner.CreateAsync(entry); + public ValueTask CreateAsync(T entry, CancellationToken cancellationToken = default) + => _inner.CreateAsync(entry, cancellationToken); - public ValueTask UpdateAsync(T entry) - => _inner.UpdateAsync(entry); + public ValueTask UpdateAsync(T entry, CancellationToken cancellationToken = default) + => _inner.UpdateAsync(entry, cancellationToken); } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs index 55ce1867..090306d6 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs @@ -21,15 +21,15 @@ public WritableNamedCatalogBindingSource(INamedCatalog inner) /// public int Order => 0; - public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) - => _inner.GetAllAsync(); + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default) + => _inner.GetAllAsync(cancellationToken); - public ValueTask DeleteAsync(T entry) - => _inner.DeleteAsync(entry); + public ValueTask DeleteAsync(T entry, CancellationToken cancellationToken = default) + => _inner.DeleteAsync(entry, cancellationToken); - public ValueTask CreateAsync(T entry) - => _inner.CreateAsync(entry); + public ValueTask CreateAsync(T entry, CancellationToken cancellationToken = default) + => _inner.CreateAsync(entry, cancellationToken); - public ValueTask UpdateAsync(T entry) - => _inner.UpdateAsync(entry); + public ValueTask UpdateAsync(T entry, CancellationToken cancellationToken = default) + => _inner.UpdateAsync(entry, cancellationToken); } diff --git a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileStore.cs b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileStore.cs index aabb467c..05ccc307 100644 --- a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileStore.cs +++ b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileStore.cs @@ -8,9 +8,6 @@ namespace CrestApps.Core.Infrastructure.Indexing; /// public interface ISearchIndexProfileStore : ICatalog, INamedCatalog { - /// - /// Gets all index profiles of the specified type (e.g., "AIDocuments", "DataSourceIndex", "AIMemory"). - /// /// Gets all index profiles of the specified type (e.g., "AIDocuments", "DataSourceIndex", "AIMemory"). /// diff --git a/src/CrestApps.Core.Docs/docs/a2a/client.md b/src/CrestApps.Core.Docs/docs/a2a/client.md index ab51708c..98bcadc1 100644 --- a/src/CrestApps.Core.Docs/docs/a2a/client.md +++ b/src/CrestApps.Core.Docs/docs/a2a/client.md @@ -423,7 +423,7 @@ Authentication metadata is stored in `A2AConnectionMetadata`: public sealed class A2AConnectionMetadata { // Which authentication type to use - public A2AClientAuthenticationType AuthenticationType { get; set; } + public ClientAuthenticationType AuthenticationType { get; set; } // API Key authentication public string ApiKeyHeaderName { get; set; } // Default: "Authorization" @@ -522,7 +522,7 @@ var connection = new A2AConnection var metadata = new A2AConnectionMetadata { - AuthenticationType = A2AClientAuthenticationType.ApiKey, + AuthenticationType = ClientAuthenticationType.ApiKey, ApiKeyHeaderName = "Authorization", ApiKeyPrefix = "Bearer", ApiKey = protector.Protect("sk-partner-key-12345"), @@ -538,7 +538,7 @@ await connectionStore.CreateAsync(connection); ```csharp var metadata = new A2AConnectionMetadata { - AuthenticationType = A2AClientAuthenticationType.OAuth2ClientCredentials, + AuthenticationType = ClientAuthenticationType.OAuth2ClientCredentials, OAuth2TokenEndpoint = "https://auth.partner.com/oauth2/token", OAuth2ClientId = "my-app-client-id", OAuth2ClientSecret = protector.Protect("my-client-secret"), @@ -553,3 +553,4 @@ var metadata = new A2AConnectionMetadata - Authentication configuration forms for all supported types - Connection assignment to AI profiles via the profile editor - Agent card preview and cache invalidation + 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 9af3df23..8c5312ba 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -34,11 +34,13 @@ description: Initial standalone release notes for the CrestApps.Core repository. - removes Azure OpenAI connection-level logging flags in favor of shared `CrestApps:AI:AzureClient` settings, keeps Azure completion resolution deployment-driven, and refreshes the AI client docs around `ClientName`-based configuration - clarifies deployment-store registration by introducing `IAIDeploymentStore` for persisted deployments, moves Chat Interactions ahead of AI Profiles / AI Chat in the MVC sample onboarding flow, and adds dedicated AI Profile documentation that explains how profiles power reusable chat, agents, orchestration, retrieval, and session processing - centralizes reusable MCP runtime registration in `AddCoreAIMcpServices()`, moves the shared MCP metadata, capability-resolution, tool-registry, SSE settings-handler, and invoke-function services into `CrestApps.Core.AI.Mcp`, and splits optional StdIO transport registration so hosts can enable it only where needed +- standardizes A2A and MCP connection authentication on the shared `ClientAuthenticationType` enum, removes the protocol-specific duplicate enums, and adds an `AzureOpenAIClientMarker` so Azure OpenAI can participate in the same provider-marker conventions as the other AI clients without changing current runtime behavior - treats aborted and canceled request-stream failures in the Aspire AppHost as observed task exceptions so local development no longer floods the console with benign unobserved-task noise - generates external `.map` source map files for all JS and CSS assets in the gulp build pipeline, copies them into `dist/` during npm package preparation, and includes them in the `@crestapps/ai-chat-ui` package exports - adds per-message text-to-speech play/pause controls on assistant messages in the AI Chat and Chat Interaction UIs, keeps the action toolbar pinned to the bottom-right of each response without reserving a separate action row, automatically stops other message players before starting a new one, and hides manual playback controls during Conversation mode - renders sample-host `[doc:n]` citations as superscript markers and shows the resolved document links below each cited assistant response in both the MVC and Blazor chat UIs - adds `AddReferenceDownloads()` plus `AddDownloadAIDocumentEndpoint()` so attached-document citation links can be registered and downloaded explicitly in sample or custom hosts +- detects when uploaded chat-interaction or chat-session documents are being used for whole-document tasks such as summarization, review, rewrite, translation, or complete extraction work, and injects the full document text instead of relying only on chunk-level RAG - upgrades the MVC sample host to Font Awesome 7 and adds draggable, resizable AI Chat widget layout persistence with a reset-size control that hosts can disable through widget config - adds Chat History page listing previous sessions per AI Profile sorted by creation date, with resume, delete, delete-all, and new-chat actions - adds Test page for Utility and Agent AI Profiles providing a single-prompt/single-response streamed UI @@ -46,3 +48,5 @@ description: Initial standalone release notes for the CrestApps.Core repository. - splits document ingestion, document-processing services, document endpoints, and document RAG into the dedicated `CrestApps.Core.AI.Documents` package, renames the format-specific helpers to `CrestApps.Core.AI.Documents.OpenXml` and `CrestApps.Core.AI.Documents.Pdf`, removes the data-ingestion dependency from `CrestApps.Core.AI`, persists uploaded files through `IDocumentFileStore` with GUID-based stored file names plus database-backed stored file metadata so hosts can redirect or clean up physical files reliably, and now registers a default filesystem-backed `IDocumentFileStore` from `AddCoreAIDocumentProcessing()` with `DocumentFileSystemFileStoreOptions` for base-path overrides - simplifies template discovery by splitting generic `Templates/` loading from prompt-only `Templates/Prompts/`, keeps generic file discovery flat so provider-specific subfolders are not double-loaded, adds `Kind`-based template selection through `ITemplateService`, suppresses duplicate template IDs with first-match wins behavior, and removes Orchard-specific embedded-resource path handling from the standalone framework templating providers - registers shared indexing services in the framework by default, including `ISearchIndexProfileManager`, `ISearchIndexProfileProvisioningService`, and a null fallback `ISearchIndexProfileStore`, so hosts only need `.AddIndexingServices(...).AddYesSqlStores()` or `.AddEntityCoreStores()` when they want persisted index profile records +- keeps the MVC and Blazor sample-host AI profile, template, and chat-edit screens usable when Claude is not configured by treating failed Claude options validation as "provider unavailable" instead of crashing the page, and removes the legacy memory-settings compatibility shim so profile/template memory state now flows only through `MemoryMetadata` +- updates the shared A2A and MCP sample clients so one client app can target either the MVC or Blazor sample host through a built-in server selector, and wires the Aspire AppHost to advertise both endpoints to those samples diff --git a/src/CrestApps.Core.Docs/docs/core/ai-core.md b/src/CrestApps.Core.Docs/docs/core/ai-core.md index b1506b91..b90c6268 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-core.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-core.md @@ -89,14 +89,14 @@ public interface IAICompletionService { Task CompleteAsync( AIDeployment deployment, - IList messages, - ChatOptions options = null, + IEnumerable messages, + AICompletionContext context, CancellationToken cancellationToken = default); - IAsyncEnumerable CompleteStreamingAsync( + IAsyncEnumerable CompleteStreamingAsync( AIDeployment deployment, - IList messages, - ChatOptions options = null, + IEnumerable messages, + AICompletionContext context, CancellationToken cancellationToken = default); } ``` @@ -125,11 +125,15 @@ Implement this interface to add a new AI provider. Each provider registers its o ```csharp public interface IAICompletionClient { + string ClientName { get; } + Task CompleteAsync( + IEnumerable messages, AICompletionContext context, CancellationToken cancellationToken = default); - IAsyncEnumerable CompleteStreamingAsync( + IAsyncEnumerable CompleteStreamingAsync( + IEnumerable messages, AICompletionContext context, CancellationToken cancellationToken = default); } @@ -307,7 +311,7 @@ To integrate an AI provider that is not already supported (e.g., Anthropic, Mist ```csharp public interface IAICompletionClient { - string Name { get; } + string ClientName { get; } Task CompleteAsync( IEnumerable messages, @@ -337,7 +341,7 @@ public sealed class MyProviderCompletionClient : IAICompletionClient _logger = logger; } - public string Name => "MyProvider"; + public string ClientName => "MyProvider"; public async Task CompleteAsync( IEnumerable messages, diff --git a/src/CrestApps.Core.Docs/docs/core/ai-documents.md b/src/CrestApps.Core.Docs/docs/core/ai-documents.md index 56cd71e0..2b85553b 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-documents.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-documents.md @@ -254,6 +254,13 @@ public interface IVectorSearchService The user's query is embedded, and the resulting vector is compared against indexed chunks using cosine similarity. +For uploaded chat-interaction and chat-session documents, the framework now switches between two context-loading strategies automatically: + +- targeted questions continue to use semantic chunk retrieval (`SearchDocumentsTool` and preemptive RAG) +- whole-document tasks such as summarizing, reviewing, rewriting, translating, or extracting complete information from an attached file inject the full document text instead of a few chunks + +That keeps RAG efficient for lookup-style questions while avoiding partial-context answers for requests that depend on the entire uploaded file. + ## Built-in Document Readers | Reader | Extensions | Embeddable | Notes | @@ -553,4 +560,3 @@ public sealed class InteractionDocumentSettings | `ReadDocumentTool` | — | System tool | Full document read | | `ReadTabularDataTool` | — | System tool | Tabular data queries | - diff --git a/src/CrestApps.Core.Docs/docs/core/ai-profiles.md b/src/CrestApps.Core.Docs/docs/core/ai-profiles.md index 80347e41..67528d80 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-profiles.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-profiles.md @@ -121,6 +121,8 @@ That turns a profile into more than a prompt container. It becomes the contract Profiles can opt into user memory so experiences can carry durable context forward between sessions instead of starting from zero every time. +That toggle is stored directly as `MemoryMetadata`, so profile and template consumers read and write one shared metadata shape instead of carrying legacy memory-setting aliases forward. + ## Profile types `AIProfile.Type` lets one model support different runtime roles. diff --git a/src/CrestApps.Core.Docs/docs/core/architecture.md b/src/CrestApps.Core.Docs/docs/core/architecture.md index 02fe0e04..ad046160 100644 --- a/src/CrestApps.Core.Docs/docs/core/architecture.md +++ b/src/CrestApps.Core.Docs/docs/core/architecture.md @@ -82,7 +82,7 @@ This page describes the project architecture and how the major layers depend on | Project | Role | |---------|------| | `CrestApps.Core.Mvc.Web` | Standalone ASP.NET Core MVC application with full admin UI | -| Blazor / Other | Future: Blazor Server/WASM, minimal APIs, etc. | +| Blazor / Other | Blazor Server/WASM (`CrestApps.Core.Blazor.Web`), minimal APIs, etc. | ## Data Flow diff --git a/src/CrestApps.Core.Docs/docs/core/core-services.md b/src/CrestApps.Core.Docs/docs/core/core-services.md index 19daaaf8..0c3c50ff 100644 --- a/src/CrestApps.Core.Docs/docs/core/core-services.md +++ b/src/CrestApps.Core.Docs/docs/core/core-services.md @@ -166,7 +166,7 @@ Validates OData filter strings before they are passed to data source backends (E ```csharp public interface IODataValidator { - bool TryValidate(string filter, out IReadOnlyList errors); + bool IsValidFilter(string filter); } ``` diff --git a/src/CrestApps.Core.Docs/docs/core/document-processing.md b/src/CrestApps.Core.Docs/docs/core/document-processing.md index 651631f4..c82e1ee6 100644 --- a/src/CrestApps.Core.Docs/docs/core/document-processing.md +++ b/src/CrestApps.Core.Docs/docs/core/document-processing.md @@ -113,6 +113,8 @@ These tools are automatically available to the orchestrator when documents are a | `ReadDocumentTool` | Reads full text of a specific document | | `ReadTabularDataTool` | Reads and parses CSV/TSV/Excel data | +For chat-interaction and chat-session uploads, the orchestration layer now chooses between chunked retrieval and full-document injection automatically. Lookup-style questions still use semantic search, while whole-document requests such as summaries, reviews, rewrites, translations, or complete extraction tasks preload the full uploaded file content into context. + ## Key Interfaces diff --git a/src/CrestApps.Core.Docs/docs/core/mvc-example.md b/src/CrestApps.Core.Docs/docs/core/mvc-example.md index d98cbdf0..65fe5a8b 100644 --- a/src/CrestApps.Core.Docs/docs/core/mvc-example.md +++ b/src/CrestApps.Core.Docs/docs/core/mvc-example.md @@ -372,4 +372,6 @@ The middleware pipeline includes: dotnet run --project .\src\Startup\CrestApps.Core.Mvc.Web\CrestApps.Core.Mvc.Web.csproj ``` +The MVC sample resolves its content root to the project directory automatically, so you can run this command from the repository root without breaking view, static-file, or `App_Data` discovery. + The application starts on `https://localhost:5001`. Configure AI provider connections in `App_Data/appsettings.json` before using AI features. diff --git a/src/CrestApps.Core.Docs/docs/core/signalr.md b/src/CrestApps.Core.Docs/docs/core/signalr.md index 4f1756af..f3a81633 100644 --- a/src/CrestApps.Core.Docs/docs/core/signalr.md +++ b/src/CrestApps.Core.Docs/docs/core/signalr.md @@ -186,8 +186,8 @@ Configure the Redis connection in your environment: ```json title="appsettings.json" { - "Configuration": "localhost:6379,allowAdmin=true" - } + "Redis": { + "Configuration": "localhost:6379,allowAdmin=true" } } ``` @@ -195,6 +195,7 @@ Configure the Redis connection in your environment: Or via environment variables: ```bash +export Redis__Configuration="localhost:6379,allowAdmin=true" ``` :::info diff --git a/src/CrestApps.Core.Docs/docs/core/tools.md b/src/CrestApps.Core.Docs/docs/core/tools.md index 2a8a5ba6..7973680a 100644 --- a/src/CrestApps.Core.Docs/docs/core/tools.md +++ b/src/CrestApps.Core.Docs/docs/core/tools.md @@ -76,7 +76,7 @@ public sealed class WeatherTool : AITool // Tool parameters are defined as a record or class private sealed record WeatherInput(string Location, string Units = "celsius"); - protected override async Task InvokeCoreAsync( + protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { diff --git a/src/CrestApps.Core.Docs/docs/getting-started.md b/src/CrestApps.Core.Docs/docs/getting-started.md index 8e59d78f..9eebaac3 100644 --- a/src/CrestApps.Core.Docs/docs/getting-started.md +++ b/src/CrestApps.Core.Docs/docs/getting-started.md @@ -51,11 +51,13 @@ dotnet test .\tests\CrestApps.Core.Tests\CrestApps.Core.Tests.csproj -c Release dotnet run --project .\src\Startup\CrestApps.Core.Mvc.Web\CrestApps.Core.Mvc.Web.csproj ``` +The sample host resolves its content root to the MVC project directory automatically, so this command works correctly when run from the repository root. + Use the MVC sample when you want to see the full framework in one place: AI providers, deployments, profiles, templates, document processing, MCP, A2A, storage, and SignalR-driven chat flows. ### Aspire host -The Aspire host boots the MVC sample and related sample clients together as a composed local environment. +The Aspire host boots the MVC and Blazor sample hosts together with the shared A2A and MCP client samples as a composed local environment. The client samples include a server selector so you can switch between the MVC and Blazor endpoints without launching separate client projects. :::info Prerequisites Aspire manages containers for services like Redis. You need a container runtime such as [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running before starting the Aspire host. @@ -132,6 +134,81 @@ Use `ConnectionName` when a deployment should point at a shared entry from `Cres Create an AI profile that uses your chat deployment, then use Chat Interactions to test it end to end. +## Complete Hello World example + +Here is a minimal, self-contained `Program.cs` that sends a chat completion request using CrestApps.Core with OpenAI: + +```csharp +using CrestApps.Core; +using CrestApps.Core.AI; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Completions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +// Register CrestApps with the OpenAI provider. +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddOpenAI() + ) +); + +var app = builder.Build(); + +// Resolve the completion service from DI. +using var scope = app.Services.CreateScope(); +var deploymentManager = scope.ServiceProvider.GetRequiredService(); +var completionService = scope.ServiceProvider.GetRequiredService(); + +// Resolve the first chat deployment. +var deployment = await deploymentManager.FindFirstByTypeAsync(AIDeploymentType.Chat); + +if (deployment is null) +{ + Console.WriteLine("No chat deployment found. Check your CrestApps:AI:Deployments configuration."); + return; +} + +// Send a completion request. +var messages = new List +{ + new(ChatRole.User, "What is CrestApps.Core in one sentence?"), +}; +var context = new AICompletionContext(); + +var response = await completionService.CompleteAsync(deployment, messages, context); +Console.WriteLine(response.Message?.Text ?? "No response."); +``` + +Add the matching `appsettings.json`: + +```json +{ + "CrestApps": { + "AI": { + "Connections": [ + { + "Name": "my-openai", + "ClientName": "OpenAI", + "ApiKey": "sk-YOUR_API_KEY_HERE" + } + ], + "Deployments": [ + { + "Name": "gpt-4.1-mini", + "ConnectionName": "my-openai", + "ModelName": "gpt-4.1-mini", + "Type": "Chat" + } + ] + } + } +} +``` + ## Learn the registration model Under the hood, each builder step still maps to the corresponding `AddCrestApps...` `IServiceCollection` extension, so hosts can still opt into the lower-level registration methods when they want that control. diff --git a/src/CrestApps.Core.Docs/docs/glossary.md b/src/CrestApps.Core.Docs/docs/glossary.md new file mode 100644 index 00000000..79721008 --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/glossary.md @@ -0,0 +1,78 @@ +--- +sidebar_label: Glossary +sidebar_position: 99 +title: Glossary +description: Definitions for key CrestApps.Core terms and concepts. +--- + +# Glossary + +Quick reference for terminology used throughout CrestApps.Core documentation. + +## Connection + +An `AIProviderConnection` holds the credentials and endpoint information for a specific AI provider (e.g., OpenAI API key, Azure OpenAI endpoint). Connections are shared across deployments and configured via `CrestApps:AI:Connections`. + +## Deployment + +An `AIDeployment` maps a logical name to a model within a connection. Each deployment specifies a model name, deployment type (Chat, Embedding, Utility, etc.), and an optional connection reference. Configured via `CrestApps:AI:Deployments`. + +## Profile + +An `AIProfile` is a high-level configuration that groups a chat deployment, system prompt, tools, and behavioral settings into a named entity. Profiles define _what_ the AI assistant does and _how_ it behaves. Use profiles when building user-facing AI experiences. + +## Chat Interaction + +A `ChatInteraction` extends a profile with UI-specific settings such as SignalR hub routes, chat widget theming, response handler names, and session management policies. Chat Interactions power the built-in playground-style chat experience. + +## Chat Session + +An `AIChatSession` tracks a single conversation between a user and an AI assistant. It stores the session ID, profile reference, user identity, status, and message history (via prompts). Sessions are persisted through an `IAIChatSessionManager`. + +## Orchestrator + +An `IOrchestrator` manages the complete lifecycle of a multi-turn AI conversation. It handles tool calling, message enrichment, streaming, and the iterative loop between the model and tools. CrestApps ships with a `DefaultOrchestrator`, plus specialized orchestrators for Claude and Copilot. + +## Catalog + +A generic CRUD abstraction (`ICatalog`) that provides `FindByIdAsync`, `GetAllAsync`, `CreateAsync`, `UpdateAsync`, and `DeleteAsync` for any entity type. Catalogs are the persistence layer interface used across the framework. + +## Store + +A concrete implementation of a catalog backed by a specific data provider. Examples: `EntityCoreAIChatSessionManager` (EF Core + SQLite), `YesSqlAIChatSessionManager` (YesSql). Stores are swappable through DI. + +## Context Builder + +An `IAICompletionContextBuilder` (or `IOrchestrationContextBuilder`) runs a pipeline of handlers that enrich a context object before it reaches the AI provider. Use context builders to inject system prompts, attach tools, apply settings, or transform the request. + +## Tool + +An `AITool` is a function the AI model can call during a conversation. Tools inherit from `AIFunction` (from `Microsoft.Extensions.AI`) and are registered via `AddCoreAITool(name)`. The orchestrator automatically selects and invokes tools based on model requests. + +## Tool Registry + +An `IToolRegistry` provides the set of tools available for a given orchestration context. Tool registries can filter tools by purpose, relevance scoring, or manual selection. Providers like MCP and A2A contribute tools through `IToolRegistryProvider`. + +## Provider + +An `IAIClientProvider` creates the SDK client objects (`IChatClient`, `IEmbeddingGenerator`) for a specific AI backend. Each provider (OpenAI, Azure OpenAI, Ollama, etc.) registers itself and is selected based on the connection's `ClientName`. + +## MCP (Model Context Protocol) + +A protocol for connecting AI models to external tools and resources. CrestApps supports MCP as both a client (consuming tools from MCP servers) and a server (exposing prompts and resources to MCP clients). + +## A2A (Agent-to-Agent Protocol) + +A protocol for AI agents to discover and communicate with each other. CrestApps supports A2A as both a client (discovering and invoking remote agents) and a host (exposing local agents to remote callers). + +## Extensible Entity + +The `ExtensibleEntity` base class provides a `Properties` dictionary for schema-flexible persistence. Any entity can store additional typed data without modifying the database schema, using `Put()` and `TryGet()`. + +## Data Source + +An `AIDataSource` configures a searchable knowledge base backed by Azure AI Search or Elasticsearch. Data sources power RAG (Retrieval-Augmented Generation) workflows where the model queries indexed documents during a conversation. + +## Search Index Profile + +A `SearchIndexProfile` defines a named search index with its backing provider, field mappings, and indexing configuration. Used by document processing and data source features to manage index lifecycle. diff --git a/src/CrestApps.Core.Docs/docs/mcp/client.md b/src/CrestApps.Core.Docs/docs/mcp/client.md index 9e7050af..0f9f640c 100644 --- a/src/CrestApps.Core.Docs/docs/mcp/client.md +++ b/src/CrestApps.Core.Docs/docs/mcp/client.md @@ -82,7 +82,7 @@ Use SSE to connect to remote MCP servers over HTTP. The `SseMcpConnectionMetadat public sealed class SseMcpConnectionMetadata { public Uri Endpoint { get; set; } - public McpClientAuthenticationType AuthenticationType { get; set; } + public ClientAuthenticationType AuthenticationType { get; set; } // API Key public string ApiKeyHeaderName { get; set; } @@ -146,7 +146,7 @@ var metadata = new StdioMcpConnectionMetadata ## Authentication -The SSE transport supports these authentication types via the `McpClientAuthenticationType` enum: +The SSE transport supports these authentication types via the `ClientAuthenticationType` enum: | Type | Description | |------|-------------| @@ -433,3 +433,4 @@ services.AddScoped(); - Authentication setup (API key, Basic, OAuth2 flows) - Assigning MCP connections to AI profiles - Viewing discovered capabilities from connected servers + diff --git a/src/CrestApps.Core.Docs/docs/providers/ollama.md b/src/CrestApps.Core.Docs/docs/providers/ollama.md index eb7cb9b2..1e174b9e 100644 --- a/src/CrestApps.Core.Docs/docs/providers/ollama.md +++ b/src/CrestApps.Core.Docs/docs/providers/ollama.md @@ -91,7 +91,7 @@ curl http://localhost:11434/api/tags ``` :::tip -This repository includes an Aspire AppHost that can orchestrate Ollama alongside the MVC sample application for local development. Run `dotnet run` from `src/Startup/CrestApps.Core.Aspire.AppHost/` to start everything together. +This repository includes an Aspire AppHost that can orchestrate Ollama alongside both the MVC and Blazor sample hosts plus the shared MCP and A2A client samples for local development. Run `dotnet run` from `src/Startup/CrestApps.Core.Aspire.AppHost/` to start everything together. ::: ## Model Management @@ -179,4 +179,3 @@ Compared to cloud providers, Ollama has several differences to be aware of: Not all Ollama models support function calling. If your application relies on [Custom AI Tools](../core/tools.md), verify that your chosen model supports tool use before deploying. Models like `llama3.2` and `mistral` have good function calling support. ::: - diff --git a/src/CrestApps.Core.Docs/docusaurus.config.js b/src/CrestApps.Core.Docs/docusaurus.config.js index b1506539..7bf3b1ee 100644 --- a/src/CrestApps.Core.Docs/docusaurus.config.js +++ b/src/CrestApps.Core.Docs/docusaurus.config.js @@ -19,7 +19,7 @@ const config = { organizationName: 'CrestApps', projectName: 'CrestApps.Core', - onBrokenLinks: 'warn', + onBrokenLinks: 'throw', i18n: { defaultLocale: 'en', diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index 913bdf9a..3f4d6eeb 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -16,6 +16,7 @@ const sidebars = { 'core/getting-started-aspnet', 'core/interfaces', 'core/extensible-entity', + 'core/ai-profiles', 'core/mvc-example', ], }, @@ -96,6 +97,7 @@ const sidebars = { 'changelog/v1.0.0', ], }, + 'glossary', ], }; diff --git a/src/Primitives/CrestApps.Core.AI.A2A/A2AConstants.cs b/src/Primitives/CrestApps.Core.AI.A2A/A2AConstants.cs index 021fa869..35cb94de 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/A2AConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/A2AConstants.cs @@ -3,4 +3,9 @@ namespace CrestApps.Core.AI.A2A; public static class A2AConstants { public const string DataProtectionPurpose = "A2AClientConnection"; + + /// + /// The name of the named used by A2A services. + /// + public const string HttpClientName = "CrestApps.A2A"; } diff --git a/src/Primitives/CrestApps.Core.AI.A2A/CrestApps.Core.AI.A2A.csproj b/src/Primitives/CrestApps.Core.AI.A2A/CrestApps.Core.AI.A2A.csproj index b0c5cf00..62b07df8 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/CrestApps.Core.AI.A2A.csproj +++ b/src/Primitives/CrestApps.Core.AI.A2A/CrestApps.Core.AI.A2A.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs index 7826cc4d..dcf74a7c 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs @@ -88,7 +88,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a try { var profileManager = arguments.Services.GetRequiredService(); - var localProfiles = await profileManager.GetAsync(AIProfileType.Agent); + var localProfiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); if (localProfiles is not null) { foreach (var profile in localProfiles) @@ -108,7 +108,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a { var connectionStore = arguments.Services.GetRequiredService>(); var agentCardCache = arguments.Services.GetRequiredService(); - var connections = await connectionStore.GetAllAsync(); + var connections = await connectionStore.GetAllAsync(cancellationToken); foreach (var connection in connections) { if (string.IsNullOrWhiteSpace(connection.Endpoint)) diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs index 0547f15b..547dcd5f 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs @@ -82,7 +82,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a var context = new AICompletionContext { - A2AConnectionIds = (await connectionStore.GetAllAsync()) + A2AConnectionIds = (await connectionStore.GetAllAsync(cancellationToken)) .Where(connection => !string.IsNullOrWhiteSpace(connection.Endpoint)) .Select(connection => connection.ItemId) .ToArray(), diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs b/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs index 6a785231..8562e5c2 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs @@ -50,7 +50,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a try { var profileManager = arguments.Services.GetRequiredService(); - var localProfiles = await profileManager.GetAsync(AIProfileType.Agent); + var localProfiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); if (localProfiles is not null) { foreach (var profile in localProfiles) @@ -68,7 +68,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a { var connectionStore = arguments.Services.GetRequiredService>(); var agentCardCache = arguments.Services.GetRequiredService(); - var connections = await connectionStore.GetAllAsync(); + var connections = await connectionStore.GetAllAsync(cancellationToken); foreach (var connection in connections) { if (string.IsNullOrWhiteSpace(connection.Endpoint)) diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AClientAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AClientAuthenticationType.cs deleted file mode 100644 index 609dd230..00000000 --- a/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AClientAuthenticationType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CrestApps.Core.AI.A2A.Models; - -public enum A2AClientAuthenticationType -{ - Anonymous, - ApiKey, - Basic, - OAuth2ClientCredentials, - OAuth2PrivateKeyJwt, - OAuth2Mtls, - CustomHeaders, -} diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AConnectionMetadata.cs b/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AConnectionMetadata.cs index b1e9604b..c011ee8b 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AConnectionMetadata.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Models/A2AConnectionMetadata.cs @@ -1,8 +1,10 @@ +using CrestApps.Core.AI.Models; + namespace CrestApps.Core.AI.A2A.Models; -public sealed class A2AConnectionMetadata +public sealed class A2AConnectionMetadata : IConnectionAuthMetadata { - public A2AClientAuthenticationType AuthenticationType { get; set; } + public ClientAuthenticationType AuthenticationType { get; set; } // API Key authentication. public string ApiKeyHeaderName { get; set; } @@ -37,4 +39,6 @@ public sealed class A2AConnectionMetadata // Custom headers (advanced). public Dictionary AdditionalHeaders { get; set; } + } + diff --git a/src/Primitives/CrestApps.Core.AI.A2A/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.A2A/ServiceCollectionExtensions.cs index 37f5d661..190615e6 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/ServiceCollectionExtensions.cs @@ -16,7 +16,9 @@ public static IServiceCollection AddCoreAIA2AClient(this IServiceCollection serv { ArgumentNullException.ThrowIfNull(services); - services.AddHttpClient(); + services.AddHttpClient(A2AConstants.HttpClientName) + .AddStandardResilienceHandler(); + services.AddMemoryCache(); services.TryAddSingleton(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs index b826319a..21284f94 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs @@ -76,14 +76,14 @@ protected override async ValueTask InvokeCoreAsync( { var httpClientFactory = arguments.Services.GetRequiredService(); - var httpClient = httpClientFactory.CreateClient(); + var httpClient = httpClientFactory.CreateClient(A2AConstants.HttpClientName); var authService = arguments.Services.GetService(); if (authService is not null) { var connectionStore = arguments.Services.GetRequiredService>(); - var connection = await connectionStore.FindByIdAsync(_connectionId); + var connection = await connectionStore.FindByIdAsync(_connectionId, cancellationToken); if (connection is not null && connection.TryGet(out var metadata)) { await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken); diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs index ec69985f..5298aca0 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs @@ -42,7 +42,7 @@ public async Task> GetToolsAsync( foreach (var connectionId in connectionIds) { - var connection = await _connectionStore.FindByIdAsync(connectionId); + var connection = await _connectionStore.FindByIdAsync(connectionId, cancellationToken); if (connection is null || string.IsNullOrWhiteSpace(connection.Endpoint)) { diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs index 96075ff2..bd47f871 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs @@ -34,7 +34,7 @@ public async Task GetAgentCardAsync(string connectionId, A2AConnectio try { - var httpClient = _httpClientFactory.CreateClient(); + var httpClient = _httpClientFactory.CreateClient(A2AConstants.HttpClientName); if (connection.TryGet(out var metadata)) { diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs index b58755ad..6fc0a15c 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs @@ -1,311 +1,29 @@ -using System.Net.Http.Json; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; using CrestApps.Core.AI.A2A.Models; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; +using CrestApps.Core.AI.Services; namespace CrestApps.Core.AI.A2A.Services; internal sealed class DefaultA2AConnectionAuthService : IA2AConnectionAuthService { - private const int ExpirationBufferSeconds = 60; + private readonly IConnectionAuthHeaderBuilder _authHeaderBuilder; - private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IMemoryCache _cache; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public DefaultA2AConnectionAuthService(IDataProtectionProvider dataProtectionProvider, IHttpClientFactory httpClientFactory, IMemoryCache cache, TimeProvider timeProvider, ILogger logger) + public DefaultA2AConnectionAuthService(IConnectionAuthHeaderBuilder authHeaderBuilder) { - _dataProtectionProvider = dataProtectionProvider; - _httpClientFactory = httpClientFactory; - _cache = cache; - _timeProvider = timeProvider; - _logger = logger; + _authHeaderBuilder = authHeaderBuilder; } public async Task> BuildHeadersAsync(A2AConnectionMetadata metadata, CancellationToken cancellationToken = default) { - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (metadata is null) - { - return headers; - } - - var protector = _dataProtectionProvider.CreateProtector(A2AConstants.DataProtectionPurpose); - switch (metadata.AuthenticationType) - { - case A2AClientAuthenticationType.ApiKey: - BuildApiKeyHeaders(metadata, protector, headers); - break; - case A2AClientAuthenticationType.Basic: - BuildBasicHeaders(metadata, protector, headers); - break; - case A2AClientAuthenticationType.OAuth2ClientCredentials: - await BuildOAuth2ClientCredentialsHeadersAsync(metadata, protector, headers, cancellationToken); - break; - case A2AClientAuthenticationType.OAuth2PrivateKeyJwt: - await BuildOAuth2PrivateKeyJwtHeadersAsync(metadata, protector, headers, cancellationToken); - break; - case A2AClientAuthenticationType.OAuth2Mtls: - await BuildOAuth2MtlsHeadersAsync(metadata, protector, headers, cancellationToken); - break; - case A2AClientAuthenticationType.CustomHeaders: - BuildCustomHeaders(metadata, headers); - break; - } - - return headers; + return await _authHeaderBuilder.BuildHeadersAsync(metadata, A2AConstants.DataProtectionPurpose, cancellationToken); } public async Task ConfigureHttpClientAsync(HttpClient httpClient, A2AConnectionMetadata metadata, CancellationToken cancellationToken = default) { var headers = await BuildHeadersAsync(metadata, cancellationToken); + foreach (var header in headers) { httpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); } } - - private void BuildApiKeyHeaders(A2AConnectionMetadata metadata, IDataProtector protector, Dictionary headers) - { - if (string.IsNullOrEmpty(metadata.ApiKey)) - { - return; - } - - var apiKey = Unprotect(protector, metadata.ApiKey); - var headerName = string.IsNullOrWhiteSpace(metadata.ApiKeyHeaderName) ? "Authorization" : metadata.ApiKeyHeaderName; - var value = !string.IsNullOrWhiteSpace(metadata.ApiKeyPrefix) ? $"{metadata.ApiKeyPrefix} {apiKey}" : apiKey; - headers[headerName] = value; - } - - private void BuildBasicHeaders(A2AConnectionMetadata metadata, IDataProtector protector, Dictionary headers) - { - if (string.IsNullOrEmpty(metadata.BasicUsername)) - { - return; - } - - var password = !string.IsNullOrEmpty(metadata.BasicPassword) ? Unprotect(protector, metadata.BasicPassword) : string.Empty; - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{metadata.BasicUsername}:{password}")); - headers["Authorization"] = $"Basic {credentials}"; - } - - private async Task BuildOAuth2ClientCredentialsHeadersAsync(A2AConnectionMetadata metadata, IDataProtector protector, Dictionary headers, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) || string.IsNullOrEmpty(metadata.OAuth2ClientId) || string.IsNullOrEmpty(metadata.OAuth2ClientSecret)) - { - return; - } - - var clientSecret = Unprotect(protector, metadata.OAuth2ClientSecret); - try - { - var token = await AcquireTokenAsync("cc", metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, metadata.OAuth2Scopes, new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = metadata.OAuth2ClientId, ["client_secret"] = clientSecret, }, cancellationToken); - headers["Authorization"] = $"Bearer {token}"; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to acquire OAuth2 token from '{TokenEndpoint}'.", metadata.OAuth2TokenEndpoint); - throw; - } - } - - private async Task BuildOAuth2PrivateKeyJwtHeadersAsync(A2AConnectionMetadata metadata, IDataProtector protector, Dictionary headers, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) || string.IsNullOrEmpty(metadata.OAuth2ClientId) || string.IsNullOrEmpty(metadata.OAuth2PrivateKey)) - { - return; - } - - var privateKey = Unprotect(protector, metadata.OAuth2PrivateKey); - var assertion = CreateClientAssertion(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, privateKey, metadata.OAuth2KeyId); - try - { - var token = await AcquireTokenAsync("pkjwt", metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, metadata.OAuth2Scopes, new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = metadata.OAuth2ClientId, ["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ["client_assertion"] = assertion, }, cancellationToken); - headers["Authorization"] = $"Bearer {token}"; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to acquire OAuth2 token via Private Key JWT from '{TokenEndpoint}'.", metadata.OAuth2TokenEndpoint); - throw; - } - } - - private async Task BuildOAuth2MtlsHeadersAsync(A2AConnectionMetadata metadata, IDataProtector protector, Dictionary headers, CancellationToken cancellationToken) - { - if (string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) || string.IsNullOrEmpty(metadata.OAuth2ClientId) || string.IsNullOrEmpty(metadata.OAuth2ClientCertificate)) - { - return; - } - - var certBase64 = Unprotect(protector, metadata.OAuth2ClientCertificate); - var certBytes = Convert.FromBase64String(certBase64); - var certPassword = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificatePassword) ? Unprotect(protector, metadata.OAuth2ClientCertificatePassword) : null; - try - { - var parameters = new Dictionary - { - ["grant_type"] = "client_credentials", - ["client_id"] = metadata.OAuth2ClientId, - }; - if (!string.IsNullOrWhiteSpace(metadata.OAuth2Scopes)) - { - parameters["scope"] = metadata.OAuth2Scopes; - } - - var cacheKey = GetOAuth2CacheKey("mtls", metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, metadata.OAuth2Scopes); - if (_cache.TryGetValue(cacheKey, out string cachedToken)) - { - headers["Authorization"] = $"Bearer {cachedToken}"; - return; - } - - var cert = string.IsNullOrEmpty(certPassword) ? X509CertificateLoader.LoadPkcs12(certBytes, null) : X509CertificateLoader.LoadPkcs12(certBytes, certPassword); - using (cert) - { - var handler = new HttpClientHandler(); - handler.ClientCertificates.Add(cert); - using var httpClient = new HttpClient(handler); - var token = await SendTokenRequestAsync(httpClient, metadata.OAuth2TokenEndpoint, parameters, cacheKey, cancellationToken); - headers["Authorization"] = $"Bearer {token}"; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to acquire OAuth2 token via mTLS from '{TokenEndpoint}'.", metadata.OAuth2TokenEndpoint); - throw; - } - } - - private static void BuildCustomHeaders(A2AConnectionMetadata metadata, Dictionary headers) - { - if (metadata.AdditionalHeaders is null) - { - return; - } - - foreach (var header in metadata.AdditionalHeaders) - { - headers[header.Key] = header.Value; - } - } - - private async Task AcquireTokenAsync(string grantType, string tokenEndpoint, string clientId, string scopes, Dictionary parameters, CancellationToken cancellationToken) - { - var cacheKey = GetOAuth2CacheKey(grantType, tokenEndpoint, clientId, scopes); - if (_cache.TryGetValue(cacheKey, out string cachedToken)) - { - return cachedToken; - } - - if (!string.IsNullOrWhiteSpace(scopes) && !parameters.ContainsKey("scope")) - { - parameters["scope"] = scopes; - } - - using var httpClient = _httpClientFactory.CreateClient(nameof(DefaultA2AConnectionAuthService)); - return await SendTokenRequestAsync(httpClient, tokenEndpoint, parameters, cacheKey, cancellationToken); - } - - private async Task SendTokenRequestAsync(HttpClient httpClient, string tokenEndpoint, Dictionary parameters, string cacheKey, CancellationToken cancellationToken) - { - using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) - { - Content = new FormUrlEncodedContent(parameters), - }; - using var response = await httpClient.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError("OAuth2 token request to '{TokenEndpoint}' failed with status {StatusCode}: {ErrorBody}", tokenEndpoint, response.StatusCode, errorBody); - throw new HttpRequestException($"OAuth2 token request failed with status {response.StatusCode}."); - } - - var tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken); - if (string.IsNullOrEmpty(tokenResponse?.AccessToken)) - { - throw new InvalidOperationException("OAuth2 token response did not contain an access token."); - } - - var expiration = tokenResponse.ExpiresIn > ExpirationBufferSeconds ? TimeSpan.FromSeconds(tokenResponse.ExpiresIn - ExpirationBufferSeconds) : TimeSpan.FromMinutes(5); - _cache.Set(cacheKey, tokenResponse.AccessToken, expiration); - return tokenResponse.AccessToken; - } - - private string CreateClientAssertion(string tokenEndpoint, string clientId, string privateKeyPem, string keyId) - { - var now = _timeProvider.GetUtcNow(); - var headerObj = new Dictionary - { - ["alg"] = "RS256", - ["typ"] = "JWT", - }; - if (!string.IsNullOrEmpty(keyId)) - { - headerObj["kid"] = keyId; - } - - var headerJson = JsonSerializer.Serialize(headerObj); - var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); - var payloadObj = new Dictionary - { - ["iss"] = clientId, - ["sub"] = clientId, - ["aud"] = tokenEndpoint, - ["jti"] = Guid.NewGuid().ToString("N"), - ["iat"] = now.ToUnixTimeSeconds(), - ["exp"] = now.AddMinutes(5).ToUnixTimeSeconds(), - }; - var payloadJson = JsonSerializer.Serialize(payloadObj); - var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); - var dataToSign = Encoding.UTF8.GetBytes($"{headerBase64}.{payloadBase64}"); - using var rsa = RSA.Create(); - rsa.ImportFromPem(privateKeyPem); - var signature = rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - var signatureBase64 = Base64UrlEncode(signature); - return $"{headerBase64}.{payloadBase64}.{signatureBase64}"; - } - - private static string Base64UrlEncode(byte[] input) - { - return Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - - private static string GetOAuth2CacheKey(string grantType, string tokenEndpoint, string clientId, string scopes) - { - return $"a2a_oauth2_{grantType}_{tokenEndpoint}_{clientId}_{scopes}"; - } - - private string Unprotect(IDataProtector protector, string value) - { - try - { - return protector.Unprotect(value); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to unprotect a credential value for A2A connection."); - return value; - } - } - - private sealed class OAuth2TokenResponse - { - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } - - [JsonPropertyName("token_type")] - public string TokenType { get; set; } - - [JsonPropertyName("expires_in")] - public int ExpiresIn { get; set; } - } } diff --git a/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs index de7af17e..803e1ef3 100644 --- a/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs @@ -1,10 +1,10 @@ -using Azure.Search.Documents.Indexes; using CrestApps.Core.AI.Azure.AISearch.Services; using CrestApps.Core.AI.Documents; using CrestApps.Core.AI.Indexing; using CrestApps.Core.AI.Memory; using CrestApps.Core.Azure.AISearch; using CrestApps.Core.Azure.AISearch.Builders; +using CrestApps.Core.Azure.AISearch.Services; using CrestApps.Core.Infrastructure.Indexing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,7 +18,7 @@ public static IServiceCollection AddCoreAzureAISearchAIDocumentSource(this IServ { ArgumentNullException.ThrowIfNull(services); - services.TryAddKeyedScoped(AISearchConstants.ProviderName, (sp, _) => new AzureAISearchVectorSearchService(sp.GetRequiredService(), sp.GetRequiredService>())); + services.TryAddKeyedScoped(AISearchConstants.ProviderName, (sp, _) => new AzureAISearchVectorSearchService(sp.GetRequiredService().CreateSearchIndexClient(), sp.GetRequiredService>())); return services.AddCoreAzureAISearchSource(IndexProfileTypes.AIDocuments, descriptor => { @@ -42,7 +42,7 @@ public static IServiceCollection AddCoreAzureAISearchAIMemorySource(this IServic { ArgumentNullException.ThrowIfNull(services); - services.TryAddKeyedScoped(AISearchConstants.ProviderName, (sp, _) => new AzureAISearchMemoryVectorSearchService(sp.GetRequiredService(), sp.GetRequiredService>())); + services.TryAddKeyedScoped(AISearchConstants.ProviderName, (sp, _) => new AzureAISearchMemoryVectorSearchService(sp.GetRequiredService().CreateSearchIndexClient(), sp.GetRequiredService>())); return services.AddCoreAzureAISearchSource(IndexProfileTypes.AIMemory, descriptor => { diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/AzureAIInferenceConstants.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/AzureAIInferenceConstants.cs index f8c8642d..904119b1 100644 --- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/AzureAIInferenceConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/AzureAIInferenceConstants.cs @@ -4,3 +4,13 @@ public static class AzureAIInferenceConstants { public const string ClientName = "AzureAIInference"; } + +/// +/// Marker type that identifies the Azure AI Inference provider for +/// . +/// +public readonly struct AzureAIInferenceClientMarker : IAIClientMarker +{ + /// + public static string ClientName => AzureAIInferenceConstants.ClientName; +} diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs index e6d98454..935a8d4f 100644 --- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CrestApps.Core.AI.AzureAIInference.Services; using CrestApps.Core.AI.Clients; +using CrestApps.Core.AI.Services; using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,7 +19,7 @@ public static IServiceCollection AddCoreAIAzureAIInference(this IServiceCollecti services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.AddCoreAIProfile(AzureAIInferenceConstants.ClientName, o => + services.AddCoreAIProfile>(AzureAIInferenceConstants.ClientName, o => { o.DisplayName = new LocalizedString("Azure AI Inference", "Azure AI Inference / GitHub Models"); o.Description = new LocalizedString("Azure AI Inference", "Use Azure AI Inference or GitHub Models for AI completion."); diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs deleted file mode 100644 index 7647e0a5..00000000 --- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CrestApps.Core.AI.Clients; -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Deployments; -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; -using CrestApps.Core.Templates.Services; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; -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, - IEnumerable handlers, - IOptions defaultOptions, - ITemplateService aiTemplateService, - IAIDeploymentManager deploymentManager) - : base(AzureAIInferenceConstants.ClientName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) - { - } -} diff --git a/src/Primitives/CrestApps.Core.AI.Chat/CrestApps.Core.AI.Chat.csproj b/src/Primitives/CrestApps.Core.AI.Chat/CrestApps.Core.AI.Chat.csproj index 4e6947c3..a2cbcaa9 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/CrestApps.Core.AI.Chat.csproj +++ b/src/Primitives/CrestApps.Core.AI.Chat/CrestApps.Core.AI.Chat.csproj @@ -12,6 +12,10 @@ $(PackageTags) ai chat signalr sessions interactions + + + + diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionEntryHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionEntryHandler.cs index 74cf2eaf..5dfadfd5 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionEntryHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionEntryHandler.cs @@ -19,7 +19,7 @@ public ChatInteractionEntryHandler( _httpContextAccessor = httpContextAccessor; } - public override Task InitializedAsync(InitializedContext context) + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) { context.Model.CreatedUtc = _timeProvider.GetUtcNow().UtcDateTime; diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataExtractionChatSessionHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataExtractionChatSessionHandler.cs index 2ad2c456..f618583d 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataExtractionChatSessionHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataExtractionChatSessionHandler.cs @@ -28,12 +28,9 @@ public DataExtractionChatSessionHandler( _logger = logger; } - public override async Task MessageCompletedAsync(ChatMessageCompletedContext context) + public override async Task MessageCompletedAsync(ChatMessageCompletedContext context, CancellationToken cancellationToken = default) { - var changeSet = await _dataExtractionService.ProcessAsync( - context.Profile, - context.ChatSession, - context.Prompts); + var changeSet = await _dataExtractionService.ProcessAsync(context.Profile, context.ChatSession, context.Prompts, cancellationToken); if (changeSet is null) { @@ -59,7 +56,7 @@ public override async Task MessageCompletedAsync(ChatMessageCompletedContext con { foreach (var recorder in _extractedDataRecorders) { - await recorder.RecordExtractedDataAsync(context.Profile, context.ChatSession); + await recorder.RecordExtractedDataAsync(context.Profile, context.ChatSession, cancellationToken); } } } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs index 47dc1ab1..7eacfc14 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs @@ -17,7 +17,7 @@ public DataSourceChatInteractionSettingsHandler(IServiceProvider serviceProvider _logger = logger; } - public async Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) + public async Task UpdatingAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { var dataSourceId = GetString(settings, "dataSourceId"); var isInScope = GetBool(settings, "isInScope") ?? false; @@ -42,7 +42,7 @@ public async Task UpdatingAsync(ChatInteraction interaction, JsonElement setting return; } - var dataSource = await dataSourceCatalog.FindByIdAsync(dataSourceId); + var dataSource = await dataSourceCatalog.FindByIdAsync(dataSourceId, cancellationToken); if (dataSource == null) { _logger.LogWarning("Chat interaction data source '{DataSourceId}' was not found while saving settings.", dataSourceId); @@ -70,7 +70,7 @@ public async Task UpdatingAsync(ChatInteraction interaction, JsonElement setting }); } - public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PostSessionProcessingChatSessionHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PostSessionProcessingChatSessionHandler.cs index 053f75f1..4f79bbb8 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PostSessionProcessingChatSessionHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PostSessionProcessingChatSessionHandler.cs @@ -22,7 +22,7 @@ public PostSessionProcessingChatSessionHandler( _logger = logger; } - public override async Task MessageCompletedAsync(ChatMessageCompletedContext context) + public override async Task MessageCompletedAsync(ChatMessageCompletedContext context, CancellationToken cancellationToken = default) { if (context.ChatSession.Status != ChatSessionStatus.Closed) { @@ -31,10 +31,7 @@ public override async Task MessageCompletedAsync(ChatMessageCompletedContext con try { - var result = await _postCloseProcessor.ProcessAsync( - context.Profile, - context.ChatSession, - context.Prompts); + var result = await _postCloseProcessor.ProcessAsync(context.Profile, context.ChatSession, context.Prompts, cancellationToken); context.Items[AIChatSessionHandlerContextKeys.PostCloseProcessingResult] = result; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs index f82e1a3f..be1aeb04 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs @@ -5,7 +5,7 @@ namespace CrestApps.Core.AI.Chat.Handlers; public sealed class PromptTemplateChatInteractionSettingsHandler : IChatInteractionSettingsHandler { - public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { interaction.Alter(metadata => { @@ -14,7 +14,7 @@ public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) return Task.CompletedTask; } - public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index 24da6262..b05f180f 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -816,7 +816,7 @@ protected virtual async Task HandleSendMessageAsync(ChannelWriter(); - var profile = await profileManager.FindByIdAsync(profileId); + var profile = await profileManager.FindByIdAsync(profileId, cancellationToken); if (profile is null) { await Clients.Caller.ReceiveError(GetProfileNotFoundMessage()); @@ -907,7 +907,7 @@ protected virtual async Task ProcessChatPromptAsync(ChannelWriter(); - var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName) + var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName, cancellationToken: cancellationToken) ?? throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the profile."); using var builder = ZString.CreateStringBuilder(); var contentItemIds = new HashSet(); @@ -1057,7 +1057,7 @@ protected virtual async Task ProcessGeneratedPromptAsync(ChannelWriter(); var deploymentManager = services.GetRequiredService(); var messageId = GenerateId(); - var completionContext = await completionContextBuilder.BuildAsync(profile); - var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName) + var completionContext = await completionContextBuilder.BuildAsync(profile, cancellationToken: cancellationToken); + var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName, cancellationToken: cancellationToken) ?? throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the profile."); var references = new Dictionary(); await foreach (var chunk in completionService.CompleteStreamingAsync(chatDeployment, [new ChatMessage(ChatRole.User, prompt)], completionContext, cancellationToken)) @@ -1472,7 +1472,7 @@ await Clients.Caller.ReceiveConversationAssistantToken( finally { sentenceChannel.Writer.TryComplete(); - sentenceBuffer.Dispose(); + if (!string.IsNullOrEmpty(messageId)) { try diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs index e7c3bcf9..9228f575 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs @@ -814,7 +814,7 @@ protected virtual async Task HandlePromptAsync( } var interactionManager = services.GetRequiredService>(); - var interaction = await interactionManager.FindByIdAsync(itemId); + var interaction = await interactionManager.FindByIdAsync(itemId, cancellationToken); if (interaction == null) { await Clients.Caller.ReceiveError(GetInteractionNotFoundMessage()); @@ -850,7 +850,7 @@ protected virtual async Task HandlePromptAsync( CreatedUtc = utcNow, }; - await promptStore.CreateAsync(userPrompt); + await promptStore.CreateAsync(userPrompt, cancellationToken); var needsTitleUpdate = string.IsNullOrEmpty(interaction.Title); if (needsTitleUpdate) @@ -892,7 +892,7 @@ protected virtual async Task HandlePromptAsync( { if (needsTitleUpdate) { - await interactionManager.UpdateAsync(interaction); + await interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); } await CommitChangesAsync(services); @@ -948,12 +948,12 @@ protected virtual async Task HandlePromptAsync( assistantPrompt.Text = builder.ToString(); assistantPrompt.References = references; await OnAssistantPromptCreatedAsync(services, assistantPrompt, contentItemIds); - await promptStore.CreateAsync(assistantPrompt); + await promptStore.CreateAsync(assistantPrompt, cancellationToken); } if (needsTitleUpdate) { - await interactionManager.UpdateAsync(interaction); + await interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); } await CommitChangesAsync(services); diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/CancelTransferNotificationActionHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/CancelTransferNotificationActionHandler.cs index a77d70b7..1ef317d3 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/CancelTransferNotificationActionHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/CancelTransferNotificationActionHandler.cs @@ -22,7 +22,7 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio if (context.ChatType == ChatContextType.AIChatSession) { var sessionManager = context.Services.GetRequiredService(); - var session = await sessionManager.FindByIdAsync(context.SessionId); + var session = await sessionManager.FindByIdAsync(context.SessionId, cancellationToken); if (session is null) { @@ -32,7 +32,7 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio } session.ResponseHandlerName = null; - await sessionManager.SaveAsync(session); + await sessionManager.SaveAsync(session, cancellationToken); if (logger.IsEnabled(LogLevel.Debug)) { @@ -42,7 +42,7 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio else if (context.ChatType == ChatContextType.ChatInteraction) { var interactionManager = context.Services.GetRequiredService>(); - var interaction = await interactionManager.FindByIdAsync(context.SessionId); + var interaction = await interactionManager.FindByIdAsync(context.SessionId, cancellationToken); if (interaction is null) { @@ -52,7 +52,7 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio } interaction.ResponseHandlerName = null; - await interactionManager.UpdateAsync(interaction); + await interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); if (logger.IsEnabled(LogLevel.Debug)) { diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs index 3a1f7c26..2e3805bb 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/EndSessionNotificationActionHandler.cs @@ -20,7 +20,7 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio if (context.ChatType == ChatContextType.AIChatSession) { var sessionManager = context.Services.GetRequiredService(); - var session = await sessionManager.FindByIdAsync(context.SessionId); + var session = await sessionManager.FindByIdAsync(context.SessionId, cancellationToken); if (session is null) { @@ -33,7 +33,7 @@ public async Task HandleAsync(ChatNotificationActionContext context, Cancellatio session.Status = ChatSessionStatus.Closed; session.ClosedAtUtc = timeProvider.GetUtcNow().UtcDateTime; - await sessionManager.SaveAsync(session); + await sessionManager.SaveAsync(session, cancellationToken); if (logger.IsEnabled(LogLevel.Debug)) { diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index ecd48941..e9cda2e4 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -4,8 +4,8 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Services; -using CrestApps.Core.Support.Json; using CrestApps.Core.Templates.Services; +using CrestApps.Core.Support.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs index bce9cc8f..c0108b75 100644 --- a/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs @@ -7,7 +7,7 @@ namespace CrestApps.Core.AI.Claude.Handlers; internal sealed class ClaudeChatInteractionSettingsHandler : IChatInteractionSettingsHandler { - public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { var orchestratorName = GetString(settings, "orchestratorName") ?? interaction.OrchestratorName; if (!string.Equals(orchestratorName, Services.ClaudeOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) @@ -25,7 +25,7 @@ public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) return Task.CompletedTask; } - public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs index e0b7713b..dc9b40bb 100644 --- a/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Claude.Handlers; +using CrestApps.Core.AI.Claude.Models; using CrestApps.Core.AI.Claude.Services; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Claude; @@ -23,6 +25,7 @@ public static IServiceCollection AddCoreAIClaudeOrchestrator(this IServiceCollec services.TryAddScoped(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, ClaudeOptionsValidator>()); return services; } diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOptionsValidator.cs b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOptionsValidator.cs new file mode 100644 index 00000000..582957ce --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOptionsValidator.cs @@ -0,0 +1,17 @@ +using CrestApps.Core.AI.Claude.Models; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Claude.Services; + +internal sealed class ClaudeOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string name, ClaudeOptions options) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + return ValidateOptionsResult.Fail("ClaudeOptions.ApiKey is required. Configure it in your appsettings under the Claude section."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/CrestApps.Core.AI.Copilot.csproj b/src/Primitives/CrestApps.Core.AI.Copilot/CrestApps.Core.AI.Copilot.csproj index b400b34d..ee8c7081 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/CrestApps.Core.AI.Copilot.csproj +++ b/src/Primitives/CrestApps.Core.AI.Copilot/CrestApps.Core.AI.Copilot.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotChatInteractionSettingsHandler.cs index 242326fc..5817fced 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotChatInteractionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotChatInteractionSettingsHandler.cs @@ -12,7 +12,7 @@ namespace CrestApps.Core.AI.Copilot.Handlers; /// internal sealed class CopilotChatInteractionSettingsHandler : IChatInteractionSettingsHandler { - public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { var orchestratorName = GetString(settings, "orchestratorName") ?? interaction.OrchestratorName; if (!string.Equals(orchestratorName, CopilotOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) @@ -32,7 +32,7 @@ public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) return Task.CompletedTask; } - public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings) + public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs index 336487e8..647d3bf9 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs @@ -1,10 +1,12 @@ using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Copilot.Handlers; +using CrestApps.Core.AI.Copilot.Models; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Copilot; @@ -17,7 +19,8 @@ public static IServiceCollection AddCoreAICopilotOrchestrator(this IServiceColle { ArgumentNullException.ThrowIfNull(services); - services.AddHttpClient(); + services.AddHttpClient(CopilotOrchestrator.HttpClientName) + .AddStandardResilienceHandler(); services.AddOrchestrator(CopilotOrchestrator.OrchestratorName) .WithTitle("Copilot"); @@ -26,6 +29,7 @@ public static IServiceCollection AddCoreAICopilotOrchestrator(this IServiceColle services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, CopilotOptionsValidator>()); return services; } diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOptionsValidator.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOptionsValidator.cs new file mode 100644 index 00000000..55718bc2 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOptionsValidator.cs @@ -0,0 +1,33 @@ +using CrestApps.Core.AI.Copilot.Models; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Copilot.Services; + +internal sealed class CopilotOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string name, CopilotOptions options) + { + if (options.AuthenticationType == CopilotAuthenticationType.GitHubOAuth) + { + if (string.IsNullOrWhiteSpace(options.ClientId)) + { + return ValidateOptionsResult.Fail("CopilotOptions.ClientId is required when AuthenticationType is GitHubOAuth."); + } + + if (string.IsNullOrWhiteSpace(options.ClientSecret)) + { + return ValidateOptionsResult.Fail("CopilotOptions.ClientSecret is required when AuthenticationType is GitHubOAuth."); + } + } + + if (options.AuthenticationType == CopilotAuthenticationType.ApiKey) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + return ValidateOptionsResult.Fail("CopilotOptions.ApiKey is required when AuthenticationType is ApiKey."); + } + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs index c94c35c2..004199e1 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs @@ -31,6 +31,12 @@ namespace CrestApps.Core.AI.Copilot.Services; public sealed class CopilotOrchestrator : IOrchestrator { public const string OrchestratorName = "copilot"; + + /// + /// The name of the named used by Copilot services. + /// + public const string HttpClientName = "CrestApps.Copilot"; + private const string TokenProtectorPurpose = "CrestApps.Core.AI.Copilot.GitHubTokens"; private readonly IToolRegistry _toolRegistry; @@ -275,7 +281,7 @@ private static PermissionRequestHandler CreatePermissionRequestHandler(bool allo return PermissionHandler.ApproveAll; } - return (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.DeniedCouldNotRequestFromUser, }); + return (request, invocation) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.UserNotAvailable, }); } private static string GetReasoningEffortValue(CopilotReasoningEffort reasoningEffort) diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs index 860e1fd1..a4a14876 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs @@ -80,9 +80,7 @@ public async Task ExchangeCodeForTokenAsync( } // Exchange authorization code for access token. - var httpClient = _httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("CrestApps-OrchardCore-Copilot/1.0"); + var httpClient = _httpClientFactory.CreateClient(CopilotOrchestrator.HttpClientName); var tokenRequest = new Dictionary { @@ -91,11 +89,14 @@ public async Task ExchangeCodeForTokenAsync( ["code"] = code }; - var tokenResponse = await httpClient.PostAsJsonAsync( - "https://github.com/login/oauth/access_token", - tokenRequest, - cancellationToken); + using var tokenRequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token") + { + Content = JsonContent.Create(tokenRequest), + }; + tokenRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + tokenRequestMessage.Headers.UserAgent.ParseAdd("CrestApps-OrchardCore-Copilot/1.0"); + var tokenResponse = await httpClient.SendAsync(tokenRequestMessage, cancellationToken); tokenResponse.EnsureSuccessStatusCode(); var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); @@ -108,9 +109,12 @@ public async Task ExchangeCodeForTokenAsync( } // Get user information from GitHub. - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + using var userRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user"); + userRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + userRequestMessage.Headers.UserAgent.ParseAdd("CrestApps-OrchardCore-Copilot/1.0"); + userRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - var userResponse = await httpClient.GetAsync("https://api.github.com/user", cancellationToken); + var userResponse = await httpClient.SendAsync(userRequestMessage, cancellationToken); userResponse.EnsureSuccessStatusCode(); var userData = await userResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/JsonFileCopilotCredentialStore.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/JsonFileCopilotCredentialStore.cs index c9983626..08f88d6c 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/JsonFileCopilotCredentialStore.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/JsonFileCopilotCredentialStore.cs @@ -22,6 +22,7 @@ public JsonFileCopilotCredentialStore(IHostEnvironment env) public async Task GetProtectedCredentialAsync(string userId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ThrowIfInvalidFileName(userId); var filePath = GetFilePath(userId); @@ -39,6 +40,7 @@ public async Task SaveProtectedCredentialAsync(string userId, CopilotProtectedCr { ArgumentException.ThrowIfNullOrWhiteSpace(userId); ArgumentNullException.ThrowIfNull(credential); + ThrowIfInvalidFileName(userId); var filePath = GetFilePath(userId); var json = JsonSerializer.Serialize(credential, _jsonOptions); @@ -49,6 +51,7 @@ public async Task SaveProtectedCredentialAsync(string userId, CopilotProtectedCr public Task ClearCredentialAsync(string userId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); + ThrowIfInvalidFileName(userId); var filePath = GetFilePath(userId); @@ -60,5 +63,14 @@ public Task ClearCredentialAsync(string userId, CancellationToken cancellationTo return Task.CompletedTask; } - private string GetFilePath(string userId) => Path.Combine(_credentialsPath, $"{userId}.json"); + private static void ThrowIfInvalidFileName(string userId) + { + if (userId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException("The userId contains invalid file name characters.", nameof(userId)); + } + } + + private string GetFilePath(string userId) + => Path.Combine(_credentialsPath, $"{userId}.json"); } diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/AIChatDocumentEndpointBase.cs b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/AIChatDocumentEndpointBase.cs index 1d44bd09..7afc2ae4 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/AIChatDocumentEndpointBase.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/AIChatDocumentEndpointBase.cs @@ -11,6 +11,8 @@ namespace CrestApps.Core.AI.Documents.Endpoints; public abstract class AIChatDocumentEndpointBase { + private const long DefaultMaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB + protected static async Task<(bool Success, string Error, AIChatUploadedDocument UploadedDocument)> ProcessFileAsync( IFormFile file, string referenceId, @@ -29,6 +31,11 @@ public abstract class AIChatDocumentEndpointBase return (false, S["No file was uploaded."].Value, null); } + if (file.Length > DefaultMaxFileSizeBytes) + { + return (false, S["The uploaded file exceeds the maximum allowed size of {0} MB.", DefaultMaxFileSizeBytes / (1024 * 1024)].Value, null); + } + var extension = Path.GetExtension(file.FileName); if (!documentOptions.AllowedFileExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs index 2e2e9001..2d65a3da 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs @@ -76,8 +76,11 @@ public async Task HandleAsync(PreemptiveRagContext context) var defaultSettings = !string.IsNullOrWhiteSpace(snapshotSettings?.IndexProfileName) ? snapshotSettings : optionsSettings; + var userSuppliedDocuments = DocumentContextInjectionModeResolver.ResolveUserSuppliedDocuments(context); + var fullUserDocumentMode = DocumentContextInjectionModeResolver.Resolve(context.OrchestrationContext, userSuppliedDocuments.Count); - if (string.IsNullOrEmpty(defaultSettings.IndexProfileName)) + if (string.IsNullOrEmpty(defaultSettings.IndexProfileName) && + fullUserDocumentMode != DocumentContextInjectionMode.FullUserDocuments) { return; } @@ -94,6 +97,131 @@ public async Task HandleAsync(PreemptiveRagContext context) private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, InteractionDocumentOptions settings) { + var searchScopes = ResolveSearchScopes(context); + + if (searchScopes.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Document Preemptive RAG: no search scopes resolved for resource type '{ResourceType}'.", context.Resource?.GetType().Name); + } + + return; + } + + var showUserDocumentAwareness = + context.Resource is not AIProfile || + searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); + + var topN = settings.TopN > 0 ? settings.TopN : 3; + var hasProfileScope = searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.Profile); + var hasSessionScope = searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); + var keepProfileDocumentAwareness = !(context.Resource is AIProfile && hasProfileScope && hasSessionScope); + var userSuppliedDocuments = DocumentContextInjectionModeResolver.ResolveUserSuppliedDocuments(context); + var fullUserDocumentMode = DocumentContextInjectionModeResolver.Resolve(context.OrchestrationContext, userSuppliedDocuments.Count); + var userDocumentContext = string.Empty; + var invocationContext = AIInvocationScope.Current; + var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (fullUserDocumentMode == DocumentContextInjectionMode.FullUserDocuments) + { + userDocumentContext = await AppendFullUserDocumentContextAsync(userSuppliedDocuments, invocationContext, seenDocuments); + searchScopes = searchScopes + .Where(scope => + scope.ReferenceType != AIReferenceTypes.Document.ChatInteraction && + scope.ReferenceType != AIReferenceTypes.Document.ChatSession) + .ToList(); + } + + var finalResults = await SearchRelevantChunksAsync(context, settings, searchScopes, topN); + + if (string.IsNullOrEmpty(userDocumentContext) && finalResults.Count == 0) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Document Preemptive RAG: no relevant document context found for the current request."); + } + + return; + } + + var orchestrationContext = context.OrchestrationContext; + + using var builder = ZString.CreateStringBuilder(); + + var arguments = new Dictionary(); + var hasUserSuppliedDocumentContext = !string.IsNullOrEmpty(userDocumentContext) || finalResults.Any(x => + x.ReferenceType == AIReferenceTypes.Document.ChatInteraction || + x.ReferenceType == AIReferenceTypes.Document.ChatSession); + var hasKnowledgeBaseDocumentContext = finalResults.Any(x => x.ReferenceType == AIReferenceTypes.Document.Profile); + + if (!orchestrationContext.DisableTools) + { + arguments["searchToolName"] = SystemToolNames.SearchDocuments; + } + + arguments["hasUserSuppliedDocumentContext"] = hasUserSuppliedDocumentContext; + arguments["hasKnowledgeBaseDocumentContext"] = hasKnowledgeBaseDocumentContext; + arguments["hasFullUserDocumentContext"] = !string.IsNullOrEmpty(userDocumentContext); + + var header = await _templateService.RenderAsync(AITemplateIds.DocumentContextHeader, arguments); + + if (!string.IsNullOrEmpty(header)) + { + builder.AppendLine(); + builder.AppendLine(); + builder.Append(header); + } + + if (!string.IsNullOrEmpty(userDocumentContext)) + { + builder.Append(userDocumentContext); + } + + if (finalResults.Count > 0) + { + if (settings.RetrievalMode == DocumentRetrievalMode.Hierarchical) + { + builder.Append(await AppendHierarchicalContextAsync(finalResults, topN, showUserDocumentAwareness, keepProfileDocumentAwareness, invocationContext, seenDocuments)); + } + else if (showUserDocumentAwareness) + { + builder.Append(AppendChunkContext(finalResults.Take(topN), keepProfileDocumentAwareness, invocationContext, seenDocuments)); + } + else + { + foreach (var (result, _) in finalResults.Take(topN)) + { + builder.AppendLine("---"); + builder.AppendLine(result.Chunk.Text); + } + } + } + + if (showUserDocumentAwareness) + { + builder.Append(AddDocumentReferences(orchestrationContext, seenDocuments)); + } + + orchestrationContext.SystemMessageBuilder.Append(builder); + } + + private async Task> SearchRelevantChunksAsync( + PreemptiveRagContext context, + InteractionDocumentOptions settings, + List<(string ResourceId, string ReferenceType)> searchScopes, + int topN) + { + if (searchScopes.Count == 0) + { + return []; + } + + if (string.IsNullOrWhiteSpace(settings.IndexProfileName)) + { + return []; + } + var indexProfile = await _indexProfileStore.FindByNameAsync(settings.IndexProfileName); if (indexProfile == null) @@ -103,7 +231,7 @@ private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, _logger.LogDebug("Document Preemptive RAG: index profile '{IndexProfileName}' not found.", settings.IndexProfileName); } - return; + return []; } var searchService = _serviceProvider.GetKeyedService(indexProfile.ProviderName); @@ -115,7 +243,7 @@ private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, _logger.LogDebug("Document Preemptive RAG: no IVectorSearchService registered for provider '{ProviderName}'.", indexProfile.ProviderName); } - return; + return []; } var metadata = SearchIndexProfileEmbeddingMetadataAccessor.GetMetadata(indexProfile); @@ -135,34 +263,16 @@ private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, metadata?.EmbeddingDeploymentId ?? indexProfile.EmbeddingDeploymentId ?? "(null)"); } - return; + return []; } var embeddings = await embeddingGenerator.GenerateAsync(context.Queries); if (embeddings == null || embeddings.Count == 0) { - return; + return []; } - var searchScopes = ResolveSearchScopes(context); - - if (searchScopes.Count == 0) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Document Preemptive RAG: no search scopes resolved for resource type '{ResourceType}'.", context.Resource?.GetType().Name); - } - - return; - } - - var showUserDocumentAwareness = - context.Resource is not AIProfile || - searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); - - var topN = settings.TopN > 0 ? settings.TopN : 3; - if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( @@ -176,9 +286,6 @@ context.Resource is not AIProfile || var allResults = new List<(DocumentChunkSearchResult Result, string ReferenceType)>(); var seenChunkKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - var hasProfileScope = searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.Profile); - var hasSessionScope = searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); - var keepProfileDocumentAwareness = !(context.Resource is AIProfile && hasProfileScope && hasSessionScope); foreach (var embedding in embeddings) { @@ -218,73 +325,9 @@ context.Resource is not AIProfile || } } - var finalResults = allResults + return allResults .OrderByDescending(r => r.Result.Score) .ToList(); - - if (finalResults.Count == 0) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Document Preemptive RAG: no relevant chunks found after vector search."); - } - - return; - } - - var orchestrationContext = context.OrchestrationContext; - - using var builder = ZString.CreateStringBuilder(); - - var arguments = new Dictionary(); - var hasUserSuppliedDocumentContext = finalResults.Any(x => - x.ReferenceType == AIReferenceTypes.Document.ChatInteraction || - x.ReferenceType == AIReferenceTypes.Document.ChatSession); - var hasKnowledgeBaseDocumentContext = finalResults.Any(x => x.ReferenceType == AIReferenceTypes.Document.Profile); - - if (!orchestrationContext.DisableTools) - { - arguments["searchToolName"] = SystemToolNames.SearchDocuments; - } - - arguments["hasUserSuppliedDocumentContext"] = hasUserSuppliedDocumentContext; - arguments["hasKnowledgeBaseDocumentContext"] = hasKnowledgeBaseDocumentContext; - - var header = await _templateService.RenderAsync(AITemplateIds.DocumentContextHeader, arguments); - - if (!string.IsNullOrEmpty(header)) - { - builder.AppendLine(); - builder.AppendLine(); - builder.Append(header); - } - - var invocationContext = AIInvocationScope.Current; - var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (settings.RetrievalMode == DocumentRetrievalMode.Hierarchical) - { - builder.Append(await AppendHierarchicalContextAsync(finalResults, topN, showUserDocumentAwareness, keepProfileDocumentAwareness, invocationContext, seenDocuments)); - } - else if (showUserDocumentAwareness) - { - builder.Append(AppendChunkContext(finalResults.Take(topN), keepProfileDocumentAwareness, invocationContext, seenDocuments)); - } - else - { - foreach (var (result, _) in finalResults.Take(topN)) - { - builder.AppendLine("---"); - builder.AppendLine(result.Chunk.Text); - } - } - - if (showUserDocumentAwareness) - { - builder.Append(AddDocumentReferences(orchestrationContext, seenDocuments)); - } - - orchestrationContext.SystemMessageBuilder.Append(builder); } private static InteractionDocumentOptions ResolveSettings(object resource, InteractionDocumentOptions defaults) @@ -397,6 +440,58 @@ private async Task AppendHierarchicalContextAsync( return builder.ToString(); } + private async Task AppendFullUserDocumentContextAsync( + IReadOnlyCollection documents, + AIInvocationContext invocationContext, + Dictionary seenDocuments) + { + if (documents.Count == 0) + { + return string.Empty; + } + + var documentStore = _serviceProvider.GetService(); + + if (documentStore == null) + { + return string.Empty; + } + + using var builder = ZString.CreateStringBuilder(); + + foreach (var documentInfo in documents + .Where(document => !string.IsNullOrWhiteSpace(document.DocumentId)) + .GroupBy(document => document.DocumentId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First())) + { + var document = await documentStore.FindByIdAsync(documentInfo.DocumentId); + + if (document == null) + { + continue; + } + + if (!seenDocuments.TryGetValue(document.ItemId, out var documentEntry)) + { + documentEntry = ( + invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1, + _textNormalizer.NormalizeTitle(document.FileName ?? documentInfo.FileName)); + + seenDocuments[document.ItemId] = documentEntry; + } + + var referenceIndex = documentEntry.Index; + + builder.AppendLine("---"); + builder.Append("[doc:"); + builder.Append(referenceIndex); + builder.AppendLine("]"); + builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(_serviceProvider, document)); + } + + return builder.ToString(); + } + private string AppendChunkContext( IEnumerable<(DocumentChunkSearchResult Result, string ReferenceType)> results, bool keepProfileDocumentAwareness, diff --git a/src/Primitives/CrestApps.Core.AI.Documents/ITabularBatchResultCache.cs b/src/Primitives/CrestApps.Core.AI.Documents/ITabularBatchResultCache.cs index 1e071846..234fad87 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/ITabularBatchResultCache.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/ITabularBatchResultCache.cs @@ -29,7 +29,7 @@ public interface ITabularBatchResultCache /// /// The cache key. /// The cached entry if found; otherwise, null. - TabularBatchCacheEntry TryGet(string cacheKey); + Task TryGetAsync(string cacheKey); /// /// Stores batch results in the cache. @@ -37,13 +37,13 @@ public interface ITabularBatchResultCache /// The cache key. /// The cache entry to store. /// Optional custom expiration time. - void Set(string cacheKey, TabularBatchCacheEntry entry, TimeSpan? expiration = null); + Task SetAsync(string cacheKey, TabularBatchCacheEntry entry, TimeSpan? expiration = null); /// /// Removes a specific cache entry. /// /// The cache key to remove. - void Remove(string cacheKey); + Task RemoveAsync(string cacheKey); /// /// Invalidates all cached results for a specific interaction. diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentContextInjectionModeResolver.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentContextInjectionModeResolver.cs new file mode 100644 index 00000000..c3cc2d2b --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentContextInjectionModeResolver.cs @@ -0,0 +1,89 @@ +using System.Text.RegularExpressions; +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Documents.Services; + +internal enum DocumentContextInjectionMode +{ + Search = 0, + FullUserDocuments = 1, +} + +internal static partial class DocumentContextInjectionModeResolver +{ + public static DocumentContextInjectionMode Resolve(OrchestrationContext context, int userDocumentCount) + { + ArgumentNullException.ThrowIfNull(context); + + if (userDocumentCount <= 0 || string.IsNullOrWhiteSpace(context.UserMessage)) + { + return DocumentContextInjectionMode.Search; + } + + var message = context.UserMessage; + + if (ExplicitWholeDocumentPattern().IsMatch(message)) + { + return DocumentContextInjectionMode.FullUserDocuments; + } + + var hasWholeDocumentTask = + WholeDocumentTaskPattern().IsMatch(message) || + WholeDocumentExtractionPattern().IsMatch(message) || + AboutDocumentPattern().IsMatch(message); + + if (!hasWholeDocumentTask) + { + return DocumentContextInjectionMode.Search; + } + + if (userDocumentCount == 1) + { + return DocumentContextInjectionMode.FullUserDocuments; + } + + if (DocumentReferencePattern().IsMatch(message)) + { + return DocumentContextInjectionMode.FullUserDocuments; + } + + return DocumentContextInjectionMode.Search; + } + + public static IReadOnlyList ResolveUserSuppliedDocuments(PreemptiveRagContext context) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Resource is ChatInteraction interaction && + interaction.Documents is { Count: > 0 }) + { + return interaction.Documents; + } + + if (context.Resource is AIProfile && + context.OrchestrationContext.CompletionContext?.AdditionalProperties is not null && + context.OrchestrationContext.CompletionContext.AdditionalProperties.TryGetValue("Session", out var sessionObject) && + sessionObject is AIChatSession session && + session.Documents is { Count: > 0 }) + { + return session.Documents; + } + + return []; + } + + [GeneratedRegex(@"\b(full|entire|whole|complete)\s+(document|file|attachment|attachments|upload|uploads|uploaded file|uploaded files|pdf|text)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ExplicitWholeDocumentPattern(); + + [GeneratedRegex(@"\b(summariz(?:e|es|ed|ing)|summaris(?:e|es|ed|ing)|summary|outline|overview|tldr|tl;dr|recap|abstract|review|critique|proofread|edit|rewrite|rephrase|improve|translate|walk me through|explain)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex WholeDocumentTaskPattern(); + + [GeneratedRegex(@"\b(list|extract|identify|find|pull|collect|enumerate|count)\b.{0,40}\b(all|every|complete|full)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline)] + private static partial Regex WholeDocumentExtractionPattern(); + + [GeneratedRegex(@"\bwhat(?:'s| is)\s+(?:this|the|these|those)?\s*(document|documents|file|files|attachment|attachments|upload|uploads|pdf)\s+about\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex AboutDocumentPattern(); + + [GeneratedRegex(@"\b(document|documents|file|files|attachment|attachments|upload|uploads|uploaded file|uploaded files|pdf|pdfs)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex DocumentReferencePattern(); +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchProcessor.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchProcessor.cs index 546c3f05..febefde1 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchProcessor.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchProcessor.cs @@ -292,7 +292,7 @@ private async Task ProcessSingleBatchAsync( using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - var deployment = await _deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: sourceContext.ChatDeploymentName) + var deployment = await _deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: sourceContext.ChatDeploymentName, cancellationToken: cancellationToken) ?? throw new InvalidOperationException("Unable to resolve a chat deployment for batch processing."); var response = await _completionService.CompleteAsync( diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchResultCache.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchResultCache.cs index cb406228..5b42e3bf 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchResultCache.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/TabularBatchResultCache.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -23,6 +24,12 @@ public sealed class TabularBatchResultCache : ITabularBatchResultCache private const string CacheKeyPrefix = "tabular_batch:"; private static readonly TimeSpan DefaultCacheExpiration = TimeSpan.FromMinutes(30); + /// + /// Tracks active cache keys per interaction so that + /// can remove them. + /// + private static readonly ConcurrentDictionary> _interactionKeys = new(); + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -48,7 +55,11 @@ public string GenerateCacheKey(string interactionId, string documentContentHash, // Create a composite key from interaction ID, document hash, and prompt hash var promptHash = ComputeHash(prompt); - return $"{CacheKeyPrefix}{interactionId}:{documentContentHash}:{promptHash}"; + var cacheKey = $"{CacheKeyPrefix}{interactionId}:{documentContentHash}:{promptHash}"; + + TrackKey(interactionId, cacheKey); + + return cacheKey; } /// @@ -74,7 +85,7 @@ public string ComputeDocumentContentHash(IEnumerable<(string FileName, string Co } /// - public TabularBatchCacheEntry TryGet(string cacheKey) + public async Task TryGetAsync(string cacheKey) { if (string.IsNullOrWhiteSpace(cacheKey)) { @@ -83,7 +94,7 @@ public TabularBatchCacheEntry TryGet(string cacheKey) try { - var cachedBytes = _cache.Get(cacheKey); + var cachedBytes = await _cache.GetAsync(cacheKey); if (cachedBytes is null || cachedBytes.Length == 0) { @@ -91,6 +102,7 @@ public TabularBatchCacheEntry TryGet(string cacheKey) { _logger.LogDebug("Cache miss for tabular batch results. Key: {CacheKey}", cacheKey); } + return null; } @@ -109,12 +121,13 @@ public TabularBatchCacheEntry TryGet(string cacheKey) catch (Exception ex) { _logger.LogWarning(ex, "Error retrieving cached batch results. Key: {CacheKey}", cacheKey); + return null; } } /// - public void Set(string cacheKey, TabularBatchCacheEntry entry, TimeSpan? expiration = null) + public async Task SetAsync(string cacheKey, TabularBatchCacheEntry entry, TimeSpan? expiration = null) { if (string.IsNullOrWhiteSpace(cacheKey) || entry is null) { @@ -132,7 +145,7 @@ public void Set(string cacheKey, TabularBatchCacheEntry entry, TimeSpan? expirat }; var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(entry, _jsonOptions); - _cache.Set(cacheKey, jsonBytes, options); + await _cache.SetAsync(cacheKey, jsonBytes, options); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -148,7 +161,7 @@ public void Set(string cacheKey, TabularBatchCacheEntry entry, TimeSpan? expirat } /// - public void Remove(string cacheKey) + public async Task RemoveAsync(string cacheKey) { if (string.IsNullOrWhiteSpace(cacheKey)) { @@ -157,7 +170,9 @@ public void Remove(string cacheKey) try { - _cache.Remove(cacheKey); + await _cache.RemoveAsync(cacheKey); + UntrackKey(cacheKey); + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Removed cached tabular batch results. Key: {CacheKey}", cacheKey); @@ -172,16 +187,43 @@ public void Remove(string cacheKey) /// public void InvalidateForInteraction(string interactionId) { - // Distributed cache doesn't support prefix-based removal directly. - // This is a limitation - entries will expire naturally. - // For production, consider using Redis with SCAN/DEL or a custom key tracking mechanism. + if (string.IsNullOrWhiteSpace(interactionId)) + { + return; + } + + if (!_interactionKeys.TryRemove(interactionId, out var keys)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "No tracked cache keys found for interaction {InteractionId}.", + interactionId); + } + + return; + } + + var removedCount = 0; + + foreach (var key in keys.Keys) + { + try + { + _cache.Remove(key); + removedCount++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error removing cached batch result during invalidation. Key: {CacheKey}", key); + } + } + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Invalidation requested for interaction {InteractionId}. " + - "Note: Distributed cache does not support prefix-based removal. " + - "Entries will expire naturally based on configured TTL.", - interactionId); + "Invalidated {Count} cached entries for interaction {InteractionId}.", + removedCount, interactionId); } } @@ -199,8 +241,24 @@ private static string ComputeHash(string input) { return string.Empty; } + var bytes = Encoding.UTF8.GetBytes(input); var hashBytes = SHA256.HashData(bytes); + return Convert.ToHexString(hashBytes)[..16]; // Truncate for shorter keys } + + private static void TrackKey(string interactionId, string cacheKey) + { + var keys = _interactionKeys.GetOrAdd(interactionId, _ => new ConcurrentDictionary()); + keys.TryAdd(cacheKey, 0); + } + + private static void UntrackKey(string cacheKey) + { + foreach (var keys in _interactionKeys.Values) + { + keys.TryRemove(cacheKey, out _); + } + } } diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs index 3ac45bcb..ff3fd87f 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs @@ -79,7 +79,7 @@ protected override async ValueTask InvokeCoreAsync( return "Document store is not available."; } - var document = await documentStore.FindByIdAsync(documentId); + var document = await documentStore.FindByIdAsync(documentId, cancellationToken); if (document is null || document.ReferenceId != chatInteractionId) { @@ -120,7 +120,7 @@ sessionObj is AIChatSession session && validReferenceIds.Add(session.SessionId); } - var document = await documentStore.FindByIdAsync(documentId); + var document = await documentStore.FindByIdAsync(documentId, cancellationToken); if (document is null || !validReferenceIds.Contains(document.ReferenceId)) { diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadTabularDataTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadTabularDataTool.cs index 49d1217e..03dec752 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadTabularDataTool.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadTabularDataTool.cs @@ -118,7 +118,7 @@ sessionObj is AIChatSession session && } // Query only documents belonging to this resource to prevent cross-session access. - var document = await documentStore.FindByIdAsync(documentId); + var document = await documentStore.FindByIdAsync(documentId, cancellationToken); if (document is null || (validReferenceIds is not null ? !validReferenceIds.Contains(document.ReferenceId) : document.ReferenceId != referenceId)) diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs index 4313f76d..741ebcac 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs @@ -108,7 +108,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } var indexProfileStore = arguments.Services.GetRequiredService(); - var indexProfile = await indexProfileStore.FindByNameAsync(settings.IndexProfileName); + var indexProfile = await indexProfileStore.FindByNameAsync(settings.IndexProfileName, cancellationToken); if (indexProfile == null) { @@ -128,9 +128,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a var aiClientFactory = arguments.Services.GetRequiredService(); var deploymentManager = arguments.Services.GetRequiredService(); - var embeddingDeployment = await deploymentManager.ResolveOrDefaultAsync( - AIDeploymentType.Embedding, - clientName: executionContext?.ClientName); + var embeddingDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Embedding, clientName: executionContext?.ClientName, cancellationToken: cancellationToken); if (embeddingDeployment == null) { diff --git a/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs index 74d90918..34fc6034 100644 --- a/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs @@ -4,8 +4,8 @@ using CrestApps.Core.AI.Memory; using CrestApps.Core.Elasticsearch; using CrestApps.Core.Elasticsearch.Builders; +using CrestApps.Core.Elasticsearch.Services; using CrestApps.Core.Infrastructure.Indexing; -using Elastic.Clients.Elasticsearch; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -19,7 +19,7 @@ public static IServiceCollection AddCoreElasticsearchAIDocumentSource(this IServ ArgumentNullException.ThrowIfNull(services); services.TryAddKeyedScoped(ElasticsearchConstants.ProviderName, (sp, _) - => new ElasticsearchVectorSearchService(sp.GetRequiredService(), sp.GetRequiredService>())); + => new ElasticsearchVectorSearchService(sp.GetRequiredService().Create(), sp.GetRequiredService>())); return services.AddCoreElasticsearchSource(IndexProfileTypes.AIDocuments, descriptor => { @@ -45,7 +45,7 @@ public static IServiceCollection AddCoreElasticsearchAIMemorySource(this IServic ArgumentNullException.ThrowIfNull(services); services.TryAddKeyedScoped(ElasticsearchConstants.ProviderName, (sp, _) - => new ElasticsearchMemoryVectorSearchService(sp.GetRequiredService(), sp.GetRequiredService>())); + => new ElasticsearchMemoryVectorSearchService(sp.GetRequiredService().Create(), sp.GetRequiredService>())); return services.AddCoreElasticsearchSource(IndexProfileTypes.AIMemory, descriptor => { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp.Ftp/Handlers/FtpResourceTypeHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp.Ftp/Handlers/FtpResourceTypeHandler.cs index 7febefc0..70ab0951 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp.Ftp/Handlers/FtpResourceTypeHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp.Ftp/Handlers/FtpResourceTypeHandler.cs @@ -40,7 +40,8 @@ protected override async Task GetResultAsync(McpResource res } var port = metadata.Port ?? 21; - var remotePath = "/" + (variables.TryGetValue("path", out var pathValue) ? pathValue : string.Empty); + var rawPath = variables.TryGetValue("path", out var pathValue) ? pathValue : string.Empty; + var remotePath = "/" + SanitizePath(rawPath); string password = null; if (!string.IsNullOrEmpty(metadata.Password)) @@ -78,6 +79,7 @@ protected override async Task GetResultAsync(McpResource res if (metadata.ValidateAnyCertificate) { + _logger.LogWarning("FTP connection to '{Host}' is configured to accept any certificate. This disables TLS validation and should only be used in development.", host); client.ValidateCertificate += (_, args) => args.Accept = true; } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp.Sftp/Handlers/SftpResourceTypeHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp.Sftp/Handlers/SftpResourceTypeHandler.cs index ec2e5f4c..ee8eb9ab 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp.Sftp/Handlers/SftpResourceTypeHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp.Sftp/Handlers/SftpResourceTypeHandler.cs @@ -42,11 +42,12 @@ protected override async Task GetResultAsync(McpResource res var protector = _dataProtectionProvider.CreateProtector(SftpResourceConstants.DataProtectionPurpose); var port = metadata.Port ?? 22; var username = metadata.Username; - var remotePath = "/" + (variables.TryGetValue("path", out var pathValue) ? pathValue : string.Empty); + var rawPath = variables.TryGetValue("path", out var pathValue) ? pathValue : string.Empty; + var remotePath = "/" + SanitizePath(rawPath); - var password = Unprotect(protector, metadata.Password, "password", resource.ItemId); - var privateKey = Unprotect(protector, metadata.PrivateKey, "private key", resource.ItemId); - var passphrase = Unprotect(protector, metadata.Passphrase, "passphrase", resource.ItemId); + var password = DataProtectionHelper.Unprotect(protector, metadata.Password, _logger, "Failed to unprotect SFTP {FieldName} for resource {ResourceId}", "password", resource.ItemId); + var privateKey = DataProtectionHelper.Unprotect(protector, metadata.PrivateKey, _logger, "Failed to unprotect SFTP {FieldName} for resource {ResourceId}", "private key", resource.ItemId); + var passphrase = DataProtectionHelper.Unprotect(protector, metadata.Passphrase, _logger, "Failed to unprotect SFTP {FieldName} for resource {ResourceId}", "passphrase", resource.ItemId); var authMethods = new List(); @@ -123,22 +124,4 @@ protected override async Task GetResultAsync(McpResource res client.Disconnect(); } } - - private string Unprotect(IDataProtector protector, string value, string fieldName, string resourceId) - { - if (string.IsNullOrEmpty(value)) - { - return null; - } - - try - { - return protector.Unprotect(value); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to unprotect SFTP {FieldName} for resource {ResourceId}", fieldName, resourceId); - return null; - } - } } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj b/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj index f7b3853d..4bb74633 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj +++ b/src/Primitives/CrestApps.Core.AI.Mcp/CrestApps.Core.AI.Mcp.csproj @@ -20,6 +20,7 @@ Model Context Protocol (MCP) implementation for CrestApps AI services. + diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs index 25f4887c..1c429712 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpInvokeFunction.cs @@ -67,7 +67,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a var inputs = GetOptionalObjectArgument(arguments, "inputs"); var store = arguments.Services.GetRequiredService>(); - var connection = await store.FindByIdAsync(clientId); + var connection = await store.FindByIdAsync(clientId, cancellationToken); if (connection is null) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs index 73fe5f15..4933d1ba 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Functions/McpToolProxyFunction.cs @@ -47,7 +47,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } var store = arguments.Services.GetRequiredService>(); - var connection = await store.FindByIdAsync(_connectionId); + var connection = await store.FindByIdAsync(_connectionId, cancellationToken); if (connection is null) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs index 5c7ee603..97cd3143 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs @@ -15,10 +15,10 @@ public SseMcpConnectionSettingsHandler(IDataProtectionProvider dataProtectionPro _dataProtectionProvider = dataProtectionProvider; } - public override Task InitializingAsync(InitializingContext context) + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) => ProtectSensitiveFieldsAsync(context.Model, context.Data); - public override Task UpdatingAsync(UpdatingContext context) + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) => ProtectSensitiveFieldsAsync(context.Model, context.Data); private Task ProtectSensitiveFieldsAsync(McpConnection connection, JsonNode data) diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs index d4c9627e..d4c12247 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpConstants.cs @@ -4,6 +4,11 @@ public static class McpConstants { public const string DataProtectionPurpose = "McpClientConnection"; + /// + /// The name of the named used by MCP services. + /// + public const string HttpClientName = "CrestApps.Mcp"; + public static class TransportTypes { public const string StdIo = "stdIo"; diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/McpResourceTypeHandlerBase.cs b/src/Primitives/CrestApps.Core.AI.Mcp/McpResourceTypeHandlerBase.cs index cf6d7df8..a79567f9 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/McpResourceTypeHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/McpResourceTypeHandlerBase.cs @@ -59,6 +59,43 @@ public static ReadResourceResult CreateErrorResult(string uri, string errorMessa }; } + /// + /// Sanitizes a user-supplied path to prevent directory traversal attacks. + /// Rejects paths containing ".." segments, null bytes, or other dangerous patterns. + /// + /// The raw path value from the user. + /// The sanitized path with leading/trailing slashes trimmed. + /// Thrown when the path contains directory traversal sequences or null bytes. + protected static string SanitizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + // Reject null bytes. + if (path.Contains('\0')) + { + throw new ArgumentException("Path contains invalid characters.", nameof(path)); + } + + // Normalize backslashes to forward slashes for consistent checking. + var normalized = path.Replace('\\', '/'); + + // Check each segment for directory traversal. + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + + foreach (var segment in segments) + { + if (segment == ".." || segment == ".") + { + throw new ArgumentException("Path must not contain directory traversal sequences.", nameof(path)); + } + } + + return string.Join("/", segments); + } + /// /// Determines whether the given MIME type represents text-based content /// that can be safely read as a string. diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAuthenticationType.cs deleted file mode 100644 index e637b6d8..00000000 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAuthenticationType.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CrestApps.Core.AI.Mcp.Models; - -public enum McpClientAuthenticationType -{ - Anonymous, - ApiKey, - Basic, - OAuth2ClientCredentials, - OAuth2PrivateKeyJwt, - OAuth2Mtls, - CustomHeaders, -} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/SseMcpConnectionMetadata.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/SseMcpConnectionMetadata.cs index e5d19d63..331247d7 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/SseMcpConnectionMetadata.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Models/SseMcpConnectionMetadata.cs @@ -1,10 +1,12 @@ +using CrestApps.Core.AI.Models; + namespace CrestApps.Core.AI.Mcp.Models; -public sealed class SseMcpConnectionMetadata +public sealed class SseMcpConnectionMetadata : IConnectionAuthMetadata { public Uri Endpoint { get; set; } - public McpClientAuthenticationType AuthenticationType { get; set; } + public ClientAuthenticationType AuthenticationType { get; set; } // API Key authentication. public string ApiKeyHeaderName { get; set; } @@ -39,4 +41,6 @@ public sealed class SseMcpConnectionMetadata // Custom headers (advanced / legacy). public Dictionary AdditionalHeaders { get; set; } + } + diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs index 0443a06f..8bf41553 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/ServiceCollectionExtensions.cs @@ -20,11 +20,13 @@ public static IServiceCollection AddCoreAIMcpServices(this IServiceCollection se services.AddMemoryCache(); services.AddDistributedMemoryCache(); - services.AddHttpClient(); + services.AddHttpClient(McpConstants.HttpClientName) + .AddStandardResilienceHandler(); + services.AddDataProtection(); services.TryAddScoped(); - services.TryAddScoped(); + services.TryAddScoped(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddScoped(); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs index 238647a1..17d9fc45 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpCapabilityResolver.cs @@ -54,7 +54,7 @@ public async Task ResolveAsync( try { - var connections = await _store.GetAsync(mcpConnectionIds); + var connections = await _store.GetAsync(mcpConnectionIds, cancellationToken); if (connections.Count == 0) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerPromptService.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerPromptService.cs index 7cab2ada..6f6df46f 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerPromptService.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerPromptService.cs @@ -57,7 +57,7 @@ public async Task GetAsync(RequestContext entry.Prompt?.Name == request.Params.Name); + var entry = (await _catalog.GetAllAsync(cancellationToken)).FirstOrDefault(entry => entry.Prompt?.Name == request.Params.Name); if (entry?.Prompt is not null) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs index f9b1f2ff..2a776f0a 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs @@ -76,7 +76,7 @@ public async Task ReadAsync(RequestContext= 0 ? afterScheme[..slashIndex] : afterScheme; - var entry = (await _catalog.GetAllAsync()).FirstOrDefault(resource => string.Equals(resource.ItemId, itemId, StringComparison.OrdinalIgnoreCase)); + var entry = (await _catalog.GetAllAsync(cancellationToken)).FirstOrDefault(resource => string.Equals(resource.ItemId, itemId, StringComparison.OrdinalIgnoreCase)); if (entry?.Resource?.Uri is null) { throw new McpException($"Resource '{uri}' not found."); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs index 360a4d70..4fc3d12a 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/McpToolRegistryProvider.cs @@ -39,7 +39,7 @@ public async Task> GetToolsAsync( return []; } - var connections = await _store.GetAsync(mcpConnectionIds); + var connections = await _store.GetAsync(mcpConnectionIds, cancellationToken); if (connections.Count == 0) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs index 3dd522cf..e0e1b9a6 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs @@ -1,22 +1,16 @@ -using System.Text; using CrestApps.Core.AI.Mcp.Models; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Logging; +using CrestApps.Core.AI.Services; using ModelContextProtocol.Client; namespace CrestApps.Core.AI.Mcp.Services; public sealed class SseClientTransportProvider : IMcpClientTransportProvider { - private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly IOAuth2TokenService _oauth2TokenService; - private readonly ILogger _logger; + private readonly IConnectionAuthHeaderBuilder _authHeaderBuilder; - public SseClientTransportProvider(IDataProtectionProvider dataProtectionProvider, IOAuth2TokenService oauth2TokenService, ILogger logger) + public SseClientTransportProvider(IConnectionAuthHeaderBuilder authHeaderBuilder) { - _dataProtectionProvider = dataProtectionProvider; - _oauth2TokenService = oauth2TokenService; - _logger = logger; + _authHeaderBuilder = authHeaderBuilder; } public bool CanHandle(McpConnection connection) @@ -31,88 +25,8 @@ public async Task GetAsync(McpConnection connection) return null; } - var headers = await BuildHeadersAsync(metadata); + var headers = await _authHeaderBuilder.BuildHeadersAsync(metadata, McpConstants.DataProtectionPurpose); return new HttpClientTransport(new HttpClientTransportOptions { Endpoint = metadata.Endpoint, AdditionalHeaders = headers, }); } - - private async Task> BuildHeadersAsync(SseMcpConnectionMetadata metadata) - { - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - var protector = _dataProtectionProvider.CreateProtector(McpConstants.DataProtectionPurpose); - switch (metadata.AuthenticationType) - { - case McpClientAuthenticationType.ApiKey: - if (!string.IsNullOrEmpty(metadata.ApiKey)) - { - var apiKey = Unprotect(protector, metadata.ApiKey); - var headerName = string.IsNullOrWhiteSpace(metadata.ApiKeyHeaderName) ? "Authorization" : metadata.ApiKeyHeaderName; - headers[headerName] = !string.IsNullOrWhiteSpace(metadata.ApiKeyPrefix) ? $"{metadata.ApiKeyPrefix} {apiKey}" : apiKey; - } - - break; - case McpClientAuthenticationType.Basic: - if (!string.IsNullOrEmpty(metadata.BasicUsername)) - { - var password = !string.IsNullOrEmpty(metadata.BasicPassword) ? Unprotect(protector, metadata.BasicPassword) : string.Empty; - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{metadata.BasicUsername}:{password}")); - headers["Authorization"] = $"Basic {credentials}"; - } - - break; - case McpClientAuthenticationType.OAuth2ClientCredentials: - if (!string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) && !string.IsNullOrEmpty(metadata.OAuth2ClientId) && !string.IsNullOrEmpty(metadata.OAuth2ClientSecret)) - { - var clientSecret = Unprotect(protector, metadata.OAuth2ClientSecret); - var token = await _oauth2TokenService.AcquireTokenAsync(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, clientSecret, metadata.OAuth2Scopes); - headers["Authorization"] = $"Bearer {token}"; - } - - break; - case McpClientAuthenticationType.OAuth2PrivateKeyJwt: - if (!string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) && !string.IsNullOrEmpty(metadata.OAuth2ClientId) && !string.IsNullOrEmpty(metadata.OAuth2PrivateKey)) - { - var privateKey = Unprotect(protector, metadata.OAuth2PrivateKey); - var token = await _oauth2TokenService.AcquireTokenWithPrivateKeyJwtAsync(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, privateKey, metadata.OAuth2KeyId, metadata.OAuth2Scopes); - headers["Authorization"] = $"Bearer {token}"; - } - - break; - case McpClientAuthenticationType.OAuth2Mtls: - if (!string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) && !string.IsNullOrEmpty(metadata.OAuth2ClientId) && !string.IsNullOrEmpty(metadata.OAuth2ClientCertificate)) - { - var certificateBytes = Convert.FromBase64String(Unprotect(protector, metadata.OAuth2ClientCertificate)); - var certificatePassword = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificatePassword) ? Unprotect(protector, metadata.OAuth2ClientCertificatePassword) : null; - var token = await _oauth2TokenService.AcquireTokenWithMtlsAsync(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, certificateBytes, certificatePassword, metadata.OAuth2Scopes); - headers["Authorization"] = $"Bearer {token}"; - } - - break; - case McpClientAuthenticationType.CustomHeaders: - if (metadata.AdditionalHeaders is not null) - { - foreach (var header in metadata.AdditionalHeaders) - { - headers[header.Key] = header.Value; - } - } - - break; - } - - return headers; - } - - private string Unprotect(IDataProtector protector, string value) - { - try - { - return protector.Unprotect(value); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to unprotect a credential value for MCP SSE connection."); - return value; - } - } } diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/OllamaConstants.cs b/src/Primitives/CrestApps.Core.AI.Ollama/OllamaConstants.cs index 31a4041d..58b84100 100644 --- a/src/Primitives/CrestApps.Core.AI.Ollama/OllamaConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.Ollama/OllamaConstants.cs @@ -4,3 +4,13 @@ public static class OllamaConstants { public const string ClientName = "Ollama"; } + +/// +/// Marker type that identifies the Ollama provider for +/// . +/// +public readonly struct OllamaClientMarker : IAIClientMarker +{ + /// + public static string ClientName => OllamaConstants.ClientName; +} diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs index 3f6076bf..f45e9085 100644 --- a/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Ollama/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Ollama.Services; +using CrestApps.Core.AI.Services; using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,7 +19,7 @@ public static IServiceCollection AddCoreAIOllama(this IServiceCollection service services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.AddCoreAIProfile(OllamaConstants.ClientName, o => + services.AddCoreAIProfile>(OllamaConstants.ClientName, o => { o.DisplayName = new LocalizedString("Ollama", "Ollama"); o.Description = new LocalizedString("Ollama", "Use locally hosted Ollama models for AI completion."); diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs index 8a381314..41e7caa0 100644 --- a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure; @@ -8,6 +9,8 @@ namespace CrestApps.Core.AI.Ollama.Services; public sealed class OllamaAIClientProvider : AIClientProviderBase { + private static readonly ConcurrentDictionary _clientCache = new(StringComparer.Ordinal); + public OllamaAIClientProvider(IServiceProvider serviceProvider) : base(serviceProvider) { } @@ -19,12 +22,12 @@ protected override string GetProviderName() protected override IChatClient GetChatClient(AIProviderConnectionEntry connection, string deploymentName) { - return new OllamaApiClient(connection.GetEndpoint(), deploymentName); + return GetOllamaClient(connection, deploymentName); } protected override IEmbeddingGenerator> GetEmbeddingGenerator(AIProviderConnectionEntry connection, string deploymentName) { - return new OllamaApiClient(connection.GetEndpoint(), deploymentName); + return GetOllamaClient(connection, deploymentName); } #pragma warning disable MEAI001 @@ -42,4 +45,25 @@ protected override ISpeechToTextClient GetSpeechToTextClient(AIProviderConnectio { throw new NotSupportedException("Ollama does not currently support speech-to-text functionality."); } + + /// + /// Clears the cached Ollama client instances, forcing new clients to be created on next use. + /// + public static void ClearCache() + { + foreach (var client in _clientCache.Values) + { + client.Dispose(); + } + + _clientCache.Clear(); + } + + private static OllamaApiClient GetOllamaClient(AIProviderConnectionEntry connection, string deploymentName) + { + var endpoint = connection.GetEndpoint(); + var cacheKey = $"{endpoint.AbsoluteUri}|{deploymentName}"; + + return _clientCache.GetOrAdd(cacheKey, _ => new OllamaApiClient(endpoint, deploymentName)); + } } diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs deleted file mode 100644 index d0e21c99..00000000 --- a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CrestApps.Core.AI.Clients; -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Deployments; -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; -using CrestApps.Core.Templates.Services; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; -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, - IEnumerable handlers, - IOptions defaultOptions, - ITemplateService aiTemplateService, - IAIDeploymentManager deploymentManager) - : base(OllamaConstants.ClientName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) - { - } -} diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/AzureOpenAIConstants.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/AzureOpenAIConstants.cs index e55c658a..71a9ae7d 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/AzureOpenAIConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/AzureOpenAIConstants.cs @@ -6,3 +6,13 @@ public static class AzureOpenAIConstants public const string AzureSpeechClientName = "AzureSpeech"; } + +/// +/// Marker type that identifies the Azure OpenAI provider for +/// . +/// +public readonly struct AzureOpenAIClientMarker : IAIClientMarker +{ + /// + public static string ClientName => AzureOpenAIConstants.ClientName; +} diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs index e1018061..61dd1994 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/ServiceCollectionExtensions.cs @@ -40,9 +40,9 @@ public static IServiceCollection AddCoreAIAzureOpenAI(this IServiceCollection se services.AddCoreAIDeploymentProvider(AzureOpenAIConstants.AzureSpeechClientName, o => { - o.SupportsContainedConnection = true; o.DisplayName = new LocalizedString("Azure AI Services", "Azure AI Services"); o.Description = new LocalizedString("Azure AI Services", "Use Azure AI Services speech deployments via configuration or the admin UI."); + o.UseContainedConnection = true; }); return services; diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientFactory.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientFactory.cs index b704b4c3..d36668d8 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientFactory.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientFactory.cs @@ -1,5 +1,6 @@ using System.ClientModel; using System.ClientModel.Primitives; +using System.Collections.Concurrent; using Azure.AI.OpenAI; using Azure.Identity; using CrestApps.Core.AI.Models; @@ -13,6 +14,13 @@ namespace CrestApps.Core.AI.OpenAI.Azure.Services; internal static class AzureOpenAIClientFactory { + private static readonly ConcurrentDictionary _clientCache = new(StringComparer.Ordinal); + + /// + /// Clears the cached Azure OpenAI client instances. + /// + public static void ClearCache() => _clientCache.Clear(); + public static AzureOpenAIClient Create( AIProviderConnectionEntry connection, ILoggerFactory loggerFactory, @@ -22,25 +30,30 @@ public static AzureOpenAIClient Create( ArgumentNullException.ThrowIfNull(loggerFactory); var endpoint = connection.GetEndpoint(); - var clientOptions = new AzureOpenAIClientOptions - { - ClientLoggingOptions = new ClientLoggingOptions - { - LoggerFactory = loggerFactory, - EnableLogging = options?.EnableLogging ?? false, - EnableMessageLogging = options?.EnableMessageLogging ?? false, - EnableMessageContentLogging = options?.EnableMessageContentLogging ?? false, - }, - }; - + var authType = connection.GetAzureAuthenticationType(); var identityId = connection.GetIdentityId(); + var cacheKey = $"{endpoint.AbsoluteUri}|{authType}|{identityId}"; - return connection.GetAzureAuthenticationType() switch + return _clientCache.GetOrAdd(cacheKey, _ => { - AzureAuthenticationType.ApiKey => new AzureOpenAIClient(endpoint, new ApiKeyCredential(connection.GetApiKey()), clientOptions), - AzureAuthenticationType.ManagedIdentity => new AzureOpenAIClient(endpoint, new ManagedIdentityCredential(string.IsNullOrEmpty(identityId) ? ManagedIdentityId.SystemAssigned : ManagedIdentityId.FromUserAssignedClientId(identityId)), clientOptions), - AzureAuthenticationType.Default => new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), clientOptions), - _ => throw new NotSupportedException("The provided authentication type is not supported."), - }; + var clientOptions = new AzureOpenAIClientOptions + { + ClientLoggingOptions = new ClientLoggingOptions + { + LoggerFactory = loggerFactory, + EnableLogging = options?.EnableLogging ?? false, + EnableMessageLogging = options?.EnableMessageLogging ?? false, + EnableMessageContentLogging = options?.EnableMessageContentLogging ?? false, + }, + }; + + return authType switch + { + AzureAuthenticationType.ApiKey => new AzureOpenAIClient(endpoint, new ApiKeyCredential(connection.GetApiKey()), clientOptions), + AzureAuthenticationType.ManagedIdentity => new AzureOpenAIClient(endpoint, new ManagedIdentityCredential(string.IsNullOrEmpty(identityId) ? ManagedIdentityId.SystemAssigned : ManagedIdentityId.FromUserAssignedClientId(identityId)), clientOptions), + AzureAuthenticationType.Default => new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), clientOptions), + _ => throw new NotSupportedException("The provided authentication type is not supported."), + }; + }); } } 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 8310b6a2..3d58d9b8 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs @@ -9,7 +9,7 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.OpenAI.Azure.Models; using CrestApps.Core.AI.Services; -using CrestApps.Core.Infrastructure; +using CrestApps.Core.Extensions; using CrestApps.Core.Templates.Services; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.DependencyInjection; @@ -488,7 +488,6 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn { DeploymentName = deploymentName, ClientName = ClientName, - ImplemenationName = ClientName, IsStreaming = false, }; @@ -538,6 +537,6 @@ private async Task RecordUsageAsync(AICompletionContext context, string connecti var record = AICompletionUsageRecordFactory.Create(context, ClientName, connectionName, deploymentName, modelName, responseId, usage?.InputTokenCount ?? 0, usage?.OutputTokenCount ?? 0, usage?.TotalTokenCount ?? 0, responseLatencyMs, isStreaming); - await observers.InvokeHandlersAsync((observer, usageRecord) => observer.UsageRecordedAsync(usageRecord, cancellationToken), record, _logger); + await observers.InvokeAsync((observer, usageRecord) => observer.UsageRecordedAsync(usageRecord, cancellationToken), record, _logger); } } diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceTextToSpeechClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceTextToSpeechClient.cs index 581be7bc..b852c931 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceTextToSpeechClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceTextToSpeechClient.cs @@ -35,6 +35,7 @@ public sealed class AzureSpeechServiceTextToSpeechClient : ITextToSpeechClient private readonly string _region; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; + private readonly SemaphoreSlim _tokenLock = new(1, 1); private string _cachedToken; private DateTimeOffset _tokenExpires; @@ -351,32 +352,40 @@ private SpeechConfig CreateEndpointConfigWithToken(string token) private async Task GetAuthorizationTokenAsync(CancellationToken cancellationToken) { - if (_cachedToken != null && _tokenExpires > _timeProvider.GetUtcNow().AddMinutes(-1)) + await _tokenLock.WaitAsync(cancellationToken); + try { - return _cachedToken; - } + if (_cachedToken != null && _tokenExpires > _timeProvider.GetUtcNow().AddMinutes(-1)) + { + return _cachedToken; + } - TokenCredential credential = _authType switch - { - AzureAuthenticationType.ManagedIdentity => string.IsNullOrEmpty(_identityId) - ? new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned) - : new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(_identityId)), - _ => new DefaultAzureCredential(), - }; + TokenCredential credential = _authType switch + { + AzureAuthenticationType.ManagedIdentity => string.IsNullOrEmpty(_identityId) + ? new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned) + : new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(_identityId)), + _ => new DefaultAzureCredential(), + }; - var tokenResult = await credential.GetTokenAsync( - new TokenRequestContext([CognitiveServicesScope]), - cancellationToken); + var tokenResult = await credential.GetTokenAsync( + new TokenRequestContext([CognitiveServicesScope]), + cancellationToken); - _cachedToken = tokenResult.Token; - _tokenExpires = tokenResult.ExpiresOn; + _cachedToken = tokenResult.Token; + _tokenExpires = tokenResult.ExpiresOn; - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Successfully obtained authorization token for Azure Speech TTS. AuthType: {AuthType}", _authType); + } + + return _cachedToken; + } + finally { - _logger.LogDebug("Successfully obtained authorization token for Azure Speech TTS. AuthType: {AuthType}", _authType); + _tokenLock.Release(); } - - return _cachedToken; } private static string TryExtractRegion(Uri endpoint) diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/OpenAIConstants.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/OpenAIConstants.cs index 3d672f9e..b4828f70 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/OpenAIConstants.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI/OpenAIConstants.cs @@ -4,3 +4,13 @@ public static class OpenAIConstants { public const string ClientName = "OpenAI"; } + +/// +/// Marker type that identifies the OpenAI provider for +/// . +/// +public readonly struct OpenAIClientMarker : IAIClientMarker +{ + /// + public static string ClientName => OpenAIConstants.ClientName; +} diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs index 30e1b843..9a50a4ad 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI/ServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.OpenAI.Handlers; -using CrestApps.Core.AI.OpenAI.Services; +using CrestApps.Core.AI.Services; using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,10 +18,10 @@ public static IServiceCollection AddCoreAIOpenAI(this IServiceCollection service { ArgumentNullException.ThrowIfNull(services); - services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.AddCoreAIProfile(OpenAIConstants.ClientName, o => + services.AddCoreAIProfile>(OpenAIConstants.ClientName, o => { o.DisplayName = new LocalizedString("OpenAI", "OpenAI"); o.Description = new LocalizedString("OpenAI", "Use OpenAI models for AI completion."); diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs index 47b08ff2..6edccb96 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs @@ -1,4 +1,7 @@ using System.ClientModel; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure; @@ -9,6 +12,8 @@ namespace CrestApps.Core.AI.OpenAI.Services; public sealed class OpenAIClientProvider : AIClientProviderBase { + private static readonly ConcurrentDictionary _clientCache = new(StringComparer.Ordinal); + public OpenAIClientProvider(IServiceProvider serviceProvider) : base(serviceProvider) { } @@ -21,12 +26,14 @@ protected override string GetProviderName() protected override IChatClient GetChatClient(AIProviderConnectionEntry connection, string deploymentName) { var client = GetOpenAIClient(connection); + return client.GetChatClient(deploymentName).AsIChatClient(); } protected override IEmbeddingGenerator> GetEmbeddingGenerator(AIProviderConnectionEntry connection, string deploymentName) { var client = GetOpenAIClient(connection); + return client.GetEmbeddingClient(deploymentName).AsIEmbeddingGenerator(); } @@ -37,6 +44,7 @@ protected override IImageGenerator GetImageGenerator(AIProviderConnectionEntry c { var client = GetOpenAIClient(connection); #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. + return client.GetImageClient(deploymentName).AsIImageGenerator(); #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. } @@ -48,18 +56,39 @@ protected override ISpeechToTextClient GetSpeechToTextClient(AIProviderConnectio { var client = GetOpenAIClient(connection); #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. + return client.GetAudioClient(deploymentName).AsISpeechToTextClient(); #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 static OpenAIClient GetOpenAIClient(AIProviderConnectionEntry connection) { + var apiKey = connection.GetApiKey(); var endpoint = connection.GetEndpoint(false); - if (endpoint is null) + var cacheKey = BuildCacheKey(endpoint, apiKey); + + return _clientCache.GetOrAdd(cacheKey, _ => { - return new OpenAIClient(connection.GetApiKey()); - } + if (endpoint is null) + { + return new OpenAIClient(apiKey); + } + + return new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions { Endpoint = endpoint, }); + }); + } + + /// + /// Clears the cached OpenAI client instances, forcing new clients to be created on next use. + /// Useful when credentials are rotated. + /// + public static void ClearCache() => _clientCache.Clear(); + + private static string BuildCacheKey(Uri endpoint, string apiKey) + { + var keyBytes = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey ?? string.Empty)); + var keyHash = Convert.ToHexStringLower(keyBytes); - return new OpenAIClient(new ApiKeyCredential(connection.GetApiKey()), new OpenAIClientOptions { Endpoint = endpoint, }); + return $"{endpoint?.AbsoluteUri}|{keyHash}"; } } diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs deleted file mode 100644 index 89e02cfa..00000000 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs +++ /dev/null @@ -1,27 +0,0 @@ -using CrestApps.Core.AI.Clients; -using CrestApps.Core.AI.Completions; -using CrestApps.Core.AI.Deployments; -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; -using CrestApps.Core.Templates.Services; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; -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, - IEnumerable handlers, - IOptions defaultOptions, - ITemplateService aiTemplateService, - IAIDeploymentManager deploymentManager) - : base(OpenAIConstants.ClientName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) - { - } -} diff --git a/src/Primitives/CrestApps.Core.AI/AIDeploymentProviderEntry.cs b/src/Primitives/CrestApps.Core.AI/AIDeploymentProviderEntry.cs index d9d37ea0..b54c17c0 100644 --- a/src/Primitives/CrestApps.Core.AI/AIDeploymentProviderEntry.cs +++ b/src/Primitives/CrestApps.Core.AI/AIDeploymentProviderEntry.cs @@ -10,8 +10,8 @@ public sealed class AIDeploymentProviderEntry /// /// When true, deployments under this provider carry their own connection - /// parameters (endpoint, credentials) in + /// parameters (endpoint, credentials) in /// instead of referencing a shared AIProviderConnection. /// - public bool SupportsContainedConnection { get; set; } + public bool UseContainedConnection { get; set; } } diff --git a/src/Primitives/CrestApps.Core.AI/AIPropertiesMergeHelper.cs b/src/Primitives/CrestApps.Core.AI/AIPropertiesMergeHelper.cs deleted file mode 100644 index 3910d232..00000000 --- a/src/Primitives/CrestApps.Core.AI/AIPropertiesMergeHelper.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using CrestApps.Core.AI.Models; - -namespace CrestApps.Core.AI; - -/// -/// After a JSON merge with MergeArrayHandling.Replace, this helper -/// post-processes known settings types to merge their named entries by name -/// (upsert) instead of fully replacing the arrays. -/// -/// Existing entries not in the incoming data are preserved; incoming entries -/// with the same name as existing entries replace them; new incoming entries -/// are appended. -/// -internal static class AIPropertiesMergeHelper -{ - public static void MergeNamedEntries(JsonObject mergedContainer, JsonObject existingSnapshot) - { - if (mergedContainer is null || existingSnapshot is null) - { - return; - } - - MergeByName( - mergedContainer, existingSnapshot, - s => s.PostSessionTasks, (s, list) => s.PostSessionTasks = list, - e => e.Name); - - MergeByName( - mergedContainer, existingSnapshot, - s => s.DataExtractionEntries, (s, list) => s.DataExtractionEntries = list, - e => e.Name); - - MergeByName( - mergedContainer, existingSnapshot, - s => s.ConversionGoals, (s, list) => s.ConversionGoals = list, - e => e.Name); - } - - private static void MergeByName( - JsonObject mergedContainer, - JsonObject existingSnapshot, - Func> getEntries, - Action> setEntries, - Func getName) - where TSettings : class, new() - { - var typeName = typeof(TSettings).Name; - - var existingNode = existingSnapshot[typeName]; - - if (existingNode is null) - { - return; - } - - var existingSettings = existingNode.Deserialize(JSOptions.CaseInsensitive); - - if (existingSettings is null) - { - return; - } - - var existingEntries = getEntries(existingSettings); - - if (existingEntries.Count == 0) - { - return; - } - - var mergedNode = mergedContainer[typeName]; - var mergedSettings = mergedNode?.Deserialize(JSOptions.CaseInsensitive) ?? new TSettings(); - var incomingEntries = getEntries(mergedSettings); - - var result = new List(existingEntries); - - foreach (var incoming in incomingEntries) - { - var name = getName(incoming); - - if (string.IsNullOrEmpty(name)) - { - continue; - } - - var existingIndex = result.FindIndex(e => - string.Equals(getName(e), name, StringComparison.OrdinalIgnoreCase)); - - if (existingIndex >= 0) - { - result[existingIndex] = incoming; - } - else - { - result.Add(incoming); - } - } - - setEntries(mergedSettings, result); - mergedContainer[typeName] = JsonSerializer.SerializeToNode(mergedSettings, JSOptions.CaseInsensitive); - } -} diff --git a/src/Primitives/CrestApps.Core.AI/AIProviderConnectionEntryLegacyDeploymentExtensions.cs b/src/Primitives/CrestApps.Core.AI/AIProviderConnectionEntryLegacyDeploymentExtensions.cs deleted file mode 100644 index f7647c8d..00000000 --- a/src/Primitives/CrestApps.Core.AI/AIProviderConnectionEntryLegacyDeploymentExtensions.cs +++ /dev/null @@ -1,39 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.Infrastructure; - -namespace CrestApps.Core.AI; - -public static class AIProviderConnectionEntryLegacyDeploymentExtensions -{ - public static string GetLegacyChatDeploymentName(this AIProviderConnectionEntry connection) - => GetLegacyString(connection, "ChatDeploymentName", "DeploymentName", "DefaultChatDeploymentName", "DefaultDeploymentName"); - - public static string GetLegacyUtilityDeploymentName(this AIProviderConnectionEntry connection) - => GetLegacyString(connection, "UtilityDeploymentName", "DefaultUtilityDeploymentName"); - - public static string GetLegacyEmbeddingDeploymentName(this AIProviderConnectionEntry connection) - => GetLegacyString(connection, "EmbeddingDeploymentName", "DefaultEmbeddingDeploymentName"); - - public static string GetLegacyImageDeploymentName(this AIProviderConnectionEntry connection) - => GetLegacyString(connection, "ImagesDeploymentName", "DefaultImagesDeploymentName"); - - public static string GetLegacySpeechToTextDeploymentName(this AIProviderConnectionEntry connection) - => GetLegacyString(connection, "SpeechToTextDeploymentName", "DefaultSpeechToTextDeploymentName"); - - private static string GetLegacyString(AIProviderConnectionEntry connection, params string[] keys) - { - ArgumentNullException.ThrowIfNull(connection); - - foreach (var key in keys) - { - var value = connection.GetStringValue(key, false); - - if (!string.IsNullOrWhiteSpace(value)) - { - return value; - } - } - - return null; - } -} diff --git a/src/Primitives/CrestApps.Core.AI/CrestApps.Core.AI.csproj b/src/Primitives/CrestApps.Core.AI/CrestApps.Core.AI.csproj index b57886d3..208003ef 100644 --- a/src/Primitives/CrestApps.Core.AI/CrestApps.Core.AI.csproj +++ b/src/Primitives/CrestApps.Core.AI/CrestApps.Core.AI.csproj @@ -27,6 +27,10 @@ + + + + diff --git a/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs b/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs index bc246a14..73a83727 100644 --- a/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs @@ -86,7 +86,7 @@ public static bool TryGetFirst(this AIFunctionArguments arguments, string key value = (T)safeValue; return true; } - catch + catch (Exception) { return false; } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIChatSessionHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIChatSessionHandlerBase.cs index bcd49a47..b1fe0357 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIChatSessionHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIChatSessionHandlerBase.cs @@ -13,7 +13,7 @@ namespace CrestApps.Core.AI.Handlers; public abstract class AIChatSessionHandlerBase : CatalogEntryHandlerBase, IAIChatSessionHandler { /// - public virtual Task MessageCompletedAsync(ChatMessageCompletedContext context) + public virtual Task MessageCompletedAsync(ChatMessageCompletedContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs index 22d425e1..c9d236bc 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs @@ -5,12 +5,12 @@ namespace CrestApps.Core.AI.Handlers; public abstract class AICompletionHandlerBase : IAICompletionHandler { - public virtual Task ReceivedMessageAsync(ReceivedMessageContext context) + public virtual Task ReceivedMessageAsync(ReceivedMessageContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } - public virtual Task ReceivedUpdateAsync(ReceivedUpdateContext context) + public virtual Task ReceivedUpdateAsync(ReceivedUpdateContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIDataSourceCatalogIndexingHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIDataSourceCatalogIndexingHandler.cs index 3879762b..68a85296 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIDataSourceCatalogIndexingHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIDataSourceCatalogIndexingHandler.cs @@ -19,7 +19,7 @@ public AIDataSourceCatalogIndexingHandler( _logger = logger; } - public override async Task CreatedAsync(CreatedContext context) + public override async Task CreatedAsync(CreatedContext context, CancellationToken cancellationToken = default) { try { @@ -28,7 +28,7 @@ public override async Task CreatedAsync(CreatedContext context) _logger.LogTrace("AI data source catalog event '{EventName}' queued full synchronization for data source '{DataSourceId}'.", nameof(CreatedAsync), context.Model.ItemId); } - await _indexingQueue.QueueSyncDataSourceAsync(context.Model); + await _indexingQueue.QueueSyncDataSourceAsync(context.Model, cancellationToken); } catch (Exception ex) { @@ -36,7 +36,7 @@ public override async Task CreatedAsync(CreatedContext context) } } - public override async Task UpdatedAsync(UpdatedContext context) + public override async Task UpdatedAsync(UpdatedContext context, CancellationToken cancellationToken = default) { try { @@ -45,7 +45,7 @@ public override async Task UpdatedAsync(UpdatedContext context) _logger.LogTrace("AI data source catalog event '{EventName}' queued full synchronization for data source '{DataSourceId}'.", nameof(UpdatedAsync), context.Model.ItemId); } - await _indexingQueue.QueueSyncDataSourceAsync(context.Model); + await _indexingQueue.QueueSyncDataSourceAsync(context.Model, cancellationToken); } catch (Exception ex) { @@ -53,7 +53,7 @@ public override async Task UpdatedAsync(UpdatedContext context) } } - public override async Task DeletedAsync(DeletedContext context) + public override async Task DeletedAsync(DeletedContext context, CancellationToken cancellationToken = default) { try { @@ -62,7 +62,7 @@ public override async Task DeletedAsync(DeletedContext context) _logger.LogTrace("AI data source catalog event '{EventName}' queued cleanup for data source '{DataSourceId}'.", nameof(DeletedAsync), context.Model.ItemId); } - await _indexingQueue.QueueDeleteDataSourceAsync(context.Model); + await _indexingQueue.QueueDeleteDataSourceAsync(context.Model, cancellationToken); } catch (Exception ex) { diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs index dff05678..1af1afac 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs @@ -16,7 +16,7 @@ public static bool IsEnabled(object resource, IOptions(out var metadata) && metadata.EnableUserMemory == true; } if (resource is ChatInteraction) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs index 201e2eb0..97e718f2 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs @@ -16,10 +16,10 @@ public AIProfileHandler( S = stringLocalizer; } - public override Task InitializingAsync(InitializingContext context) + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) => PopulateAsync(context.Model, context.Data); - public override Task UpdatingAsync(UpdatingContext context) + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) => PopulateAsync(context.Model, context.Data); private static Task PopulateAsync(AIProfile profile, JsonNode data) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/FunctionInvocationAICompletionServiceHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/FunctionInvocationAICompletionServiceHandler.cs index 3c5b3afa..db2320ba 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/FunctionInvocationAICompletionServiceHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/FunctionInvocationAICompletionServiceHandler.cs @@ -36,7 +36,7 @@ public FunctionInvocationAICompletionServiceHandler( _logger = logger; } - public async Task ConfigureAsync(CompletionServiceConfigureContext context) + public async Task ConfigureAsync(CompletionServiceConfigureContext context, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(context); diff --git a/src/Primitives/CrestApps.Core.AI/IAIClientMarker.cs b/src/Primitives/CrestApps.Core.AI/IAIClientMarker.cs new file mode 100644 index 00000000..8b0d88c0 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/IAIClientMarker.cs @@ -0,0 +1,14 @@ +namespace CrestApps.Core.AI; + +/// +/// Identifies an AI provider by its client name. Implement this interface on a +/// marker type so +/// can resolve the name at compile time. +/// +public interface IAIClientMarker +{ + /// + /// Gets the unique client name that identifies the provider. + /// + static abstract string ClientName { get; } +} diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs index e1e91718..01a1fb20 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs @@ -41,7 +41,7 @@ public override async ValueTask ValidateAsync(SearchIndexProfile indexProfile, V return; } - var deployment = await _deploymentCatalog.FindByIdAsync(indexProfile.EmbeddingDeploymentId); + var deployment = await _deploymentCatalog.FindByIdAsync(indexProfile.EmbeddingDeploymentId, cancellationToken); if (deployment == null) { result.Fail(new ValidationResult("The selected embedding deployment could not be found.", [nameof(SearchIndexProfile.EmbeddingDeploymentId)])); @@ -79,7 +79,7 @@ protected bool CanHandle(SearchIndexProfile indexProfile) protected abstract IReadOnlyCollection BuildFields(int vectorDimensions); private async Task GetEmbeddingDimensionsAsync(SearchIndexProfile indexProfile, CancellationToken cancellationToken) { - var deployment = await _deploymentCatalog.FindByIdAsync(indexProfile.EmbeddingDeploymentId); + var deployment = await _deploymentCatalog.FindByIdAsync(indexProfile.EmbeddingDeploymentId, cancellationToken); if (deployment == null) { throw new InvalidOperationException("The selected embedding deployment could not be found."); diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs index 99d29644..635b8c95 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs @@ -1,6 +1,6 @@ -using CrestApps.Core.Handlers; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Handlers; using CrestApps.Core.Models; namespace CrestApps.Core.AI.Indexing; @@ -32,13 +32,13 @@ public virtual Task DeletingAsync(SearchIndexProfile indexProfile, CancellationT return Task.CompletedTask; } - public override async Task ValidatingAsync(ValidatingContext context) + public override async Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) { - await ValidateAsync(context.Model, context.Result); + await ValidateAsync(context.Model, context.Result, cancellationToken); } - public override async Task DeletingAsync(DeletingContext context) + public override async Task DeletingAsync(DeletingContext context, CancellationToken cancellationToken = default) { - await DeletingAsync(context.Model); + await DeletingAsync(context.Model, cancellationToken); } } diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs b/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs index 6461ca23..9f6994f1 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs @@ -10,26 +10,26 @@ namespace CrestApps.Core.AI.Indexing; /// public sealed class NullSearchIndexProfileStore : ISearchIndexProfileStore { - public ValueTask FindByIdAsync(string id) + public ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(id); return ValueTask.FromResult(default); } - public ValueTask> GetAllAsync() + public ValueTask> GetAllAsync(CancellationToken cancellationToken = default) { return ValueTask.FromResult>([]); } - public ValueTask> GetAsync(IEnumerable ids) + public ValueTask> GetAsync(IEnumerable ids, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(ids); return ValueTask.FromResult>([]); } - public ValueTask> PageAsync(int page, int pageSize, TQuery context) + public ValueTask> PageAsync(int page, int pageSize, TQuery context, CancellationToken cancellationToken = default) where TQuery : QueryContext { ArgumentNullException.ThrowIfNull(context); @@ -41,28 +41,28 @@ public ValueTask> PageAsync(int page, int }); } - public ValueTask DeleteAsync(SearchIndexProfile entry) + public ValueTask DeleteAsync(SearchIndexProfile entry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); return ValueTask.FromResult(false); } - public ValueTask CreateAsync(SearchIndexProfile entry) + public ValueTask CreateAsync(SearchIndexProfile entry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); return ValueTask.CompletedTask; } - public ValueTask UpdateAsync(SearchIndexProfile entry) + public ValueTask UpdateAsync(SearchIndexProfile entry, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); return ValueTask.CompletedTask; } - public ValueTask FindByNameAsync(string name) + public ValueTask FindByNameAsync(string name, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(name); diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs index 59bc6703..1d0a3ce2 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs @@ -28,7 +28,21 @@ public async Task CreateAsync(SearchIndexProfile profil { ArgumentNullException.ThrowIfNull(profile); - var indexManager = _serviceProvider.GetKeyedService(profile.ProviderName); + ISearchIndexManager indexManager; + try + { + indexManager = _serviceProvider.GetKeyedService(profile.ProviderName); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning( + ex, + "Search provider '{ProviderName}' could not be resolved for remote index provisioning.", + profile.ProviderName.SanitizeForLog()); + + return Fail("The selected search provider is not configured for remote index provisioning.", nameof(SearchIndexProfile.ProviderName)); + } + if (indexManager == null) { return Fail("The selected search provider is not configured for remote index provisioning.", nameof(SearchIndexProfile.ProviderName)); @@ -36,7 +50,7 @@ public async Task CreateAsync(SearchIndexProfile profil profile.IndexFullName = indexManager.ComposeIndexFullName(profile); - var validationResult = await _indexProfileManager.ValidateAsync(profile); + var validationResult = await _indexProfileManager.ValidateAsync(profile, cancellationToken); if (!validationResult.Succeeded) { return validationResult; @@ -92,7 +106,7 @@ public async Task CreateAsync(SearchIndexProfile profil try { - await _indexProfileManager.CreateAsync(profile); + await _indexProfileManager.CreateAsync(profile, cancellationToken); } catch { diff --git a/src/Primitives/CrestApps.Core.AI/LuceneTextTokenizer.cs b/src/Primitives/CrestApps.Core.AI/LuceneTextTokenizer.cs index 1282a87a..ae4dba08 100644 --- a/src/Primitives/CrestApps.Core.AI/LuceneTextTokenizer.cs +++ b/src/Primitives/CrestApps.Core.AI/LuceneTextTokenizer.cs @@ -23,14 +23,15 @@ namespace CrestApps.Core.AI; /// Thread-safe: uses a shared instance with per-thread /// TokenStream pooling. /// -public sealed class LuceneTextTokenizer : ITextTokenizer +public sealed partial class LuceneTextTokenizer : ITextTokenizer { private const LuceneVersion _luceneVersion = LuceneVersion.LUCENE_48; // Inserts a space between consecutive uppercase sequences and the start of a new word. // Handles a known limitation of Lucene 4.x WordDelimiterFilter where UPPER→letter // transitions don't trigger splits (e.g., "JSONSchema" → "JSON Schema"). - private static readonly Regex _consecutiveUppercasePattern = new(@"(?<=[A-Z])(?=[A-Z][a-z])", RegexOptions.Compiled); + [GeneratedRegex(@"(?<=[A-Z])(?=[A-Z][a-z])")] + private static partial Regex ConsecutiveUppercasePattern(); // Shared analyzer instance. Lucene.NET analyzers are thread-safe for GetTokenStream() // (the default reuse strategy uses per-thread TokenStream pooling via CloseableThreadLocal). @@ -46,7 +47,7 @@ public HashSet Tokenize(string text) // Pre-process: split consecutive uppercase sequences before a new word // (e.g., "JSONSchema" → "JSON Schema", "MCPServer" → "MCP Server"). - text = _consecutiveUppercasePattern.Replace(text, " "); + text = ConsecutiveUppercasePattern().Replace(text, " "); var tokens = new HashSet(StringComparer.Ordinal); diff --git a/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs b/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs deleted file mode 100644 index b6ea37b7..00000000 --- a/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CrestApps.Core.AI.Memory; - -public sealed class AIMemorySettings -{ - public string IndexProfileName { get; set; } - - public int TopN { get; set; } = 5; -} diff --git a/src/Primitives/CrestApps.Core.AI/Models/ClientAuthenticationType.cs b/src/Primitives/CrestApps.Core.AI/Models/ClientAuthenticationType.cs new file mode 100644 index 00000000..0c637ec9 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Models/ClientAuthenticationType.cs @@ -0,0 +1,42 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Specifies the authentication mechanism used for protocol connections (MCP, A2A, etc.). +/// +public enum ClientAuthenticationType +{ + /// + /// No authentication required. + /// + Anonymous, + + /// + /// API key-based authentication. + /// + ApiKey, + + /// + /// HTTP Basic authentication. + /// + Basic, + + /// + /// OAuth 2.0 Client Credentials grant. + /// + OAuth2ClientCredentials, + + /// + /// OAuth 2.0 Private Key JWT client assertion. + /// + OAuth2PrivateKeyJwt, + + /// + /// OAuth 2.0 Mutual TLS (mTLS) client certificate authentication. + /// + OAuth2Mtls, + + /// + /// Custom HTTP headers for advanced or legacy authentication. + /// + CustomHeaders, +} diff --git a/src/Primitives/CrestApps.Core.AI/Models/IConnectionAuthMetadata.cs b/src/Primitives/CrestApps.Core.AI/Models/IConnectionAuthMetadata.cs new file mode 100644 index 00000000..14953a83 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Models/IConnectionAuthMetadata.cs @@ -0,0 +1,85 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Describes the authentication metadata for a protocol connection. +/// Implemented by protocol-specific metadata classes (MCP SSE, A2A, etc.) +/// to enable a shared authentication header builder. +/// +public interface IConnectionAuthMetadata +{ + /// + /// Gets the authentication type for this connection. + /// + ClientAuthenticationType AuthenticationType { get; } + + /// + /// Gets the custom header name for API key authentication. + /// When or empty, defaults to "Authorization". + /// + string ApiKeyHeaderName { get; } + + /// + /// Gets the prefix for the API key value (e.g., "Bearer", "Api-Key"). + /// + string ApiKeyPrefix { get; } + + /// + /// Gets the protected API key value. + /// + string ApiKey { get; } + + /// + /// Gets the username for HTTP Basic authentication. + /// + string BasicUsername { get; } + + /// + /// Gets the protected password for HTTP Basic authentication. + /// + string BasicPassword { get; } + + /// + /// Gets the OAuth 2.0 token endpoint URL. + /// + string OAuth2TokenEndpoint { get; } + + /// + /// Gets the OAuth 2.0 client ID. + /// + string OAuth2ClientId { get; } + + /// + /// Gets the protected OAuth 2.0 client secret. + /// + string OAuth2ClientSecret { get; } + + /// + /// Gets the OAuth 2.0 scopes (space-separated). + /// + string OAuth2Scopes { get; } + + /// + /// Gets the protected OAuth 2.0 private key for JWT client assertion. + /// + string OAuth2PrivateKey { get; } + + /// + /// Gets the key ID for OAuth 2.0 Private Key JWT authentication. + /// + string OAuth2KeyId { get; } + + /// + /// Gets the protected OAuth 2.0 client certificate (Base64-encoded). + /// + string OAuth2ClientCertificate { get; } + + /// + /// Gets the protected password for the OAuth 2.0 client certificate. + /// + string OAuth2ClientCertificatePassword { get; } + + /// + /// Gets additional custom HTTP headers. + /// + Dictionary AdditionalHeaders { get; } +} diff --git a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs deleted file mode 100644 index a922d80f..00000000 --- a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace CrestApps.Core.AI.Models; - -public static class MemoryMetadataExtensions -{ - public const string LegacyAIProfileSettingsKey = "AIProfileMemorySettings"; - public const string LegacyChatInteractionSettingsKey = "ChatInteractionMemorySettings"; - public const string LegacyMvcMemorySettingsKey = "MemorySettings"; - private static readonly JsonSerializerOptions _serializerOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - public static MemoryMetadata GetMemoryMetadata(this AIProfile profile) - { - ArgumentNullException.ThrowIfNull(profile); - - if (profile.TryGet(out var memoryMetadata)) - { - return memoryMetadata; - } - - if (TryDeserialize(profile.Settings?[LegacyAIProfileSettingsKey], out MemoryMetadata metadata) || TryDeserialize(profile.Settings?[LegacyMvcMemorySettingsKey], out metadata)) - { - return metadata; - } - - return new MemoryMetadata(); - } - - public static AIProfile AlterMemoryMetadata(this AIProfile profile, Action alter) - { - ArgumentNullException.ThrowIfNull(profile); - ArgumentNullException.ThrowIfNull(alter); - profile.Alter(alter); - profile.Settings?.Remove(LegacyAIProfileSettingsKey); - profile.Settings?.Remove(LegacyMvcMemorySettingsKey); - return profile; - } - - public static MemoryMetadata GetMemoryMetadata(this AIProfileTemplate template) - { - ArgumentNullException.ThrowIfNull(template); - - if (template.TryGet(out var memoryMetadata)) - { - return memoryMetadata; - } - - if (TryDeserialize(GetPropertyValue(template.Properties, LegacyAIProfileSettingsKey), out MemoryMetadata metadata) || TryDeserialize(GetPropertyValue(template.Properties, LegacyMvcMemorySettingsKey), out metadata)) - { - return metadata; - } - - return new MemoryMetadata(); - } - - public static AIProfileTemplate WithMemoryMetadata(this AIProfileTemplate template, MemoryMetadata metadata) - { - ArgumentNullException.ThrowIfNull(template); - ArgumentNullException.ThrowIfNull(metadata); - template.Put(metadata); - template.Properties.Remove(LegacyAIProfileSettingsKey); - template.Properties.Remove(LegacyMvcMemorySettingsKey); - return template; - } - - public static bool TryDeserialize(object value, out MemoryMetadata metadata) - { - metadata = Deserialize(value); - return metadata is not null; - } - - private static MemoryMetadata Deserialize(object value) - { - if (value is null) - { - return null; - } - - if (value is MemoryMetadata metadata) - { - return metadata; - } - - if (value is JsonNode node) - { - return node.Deserialize(_serializerOptions); - } - - if (value is JsonElement element) - { - return element.ValueKind == JsonValueKind.Null ? null : element.Deserialize(_serializerOptions); - } - - var json = JsonSerializer.Serialize(value, _serializerOptions); - return JsonSerializer.Deserialize(json, _serializerOptions); - } - - private static object GetPropertyValue(IDictionary properties, string key) - { - return properties is not null && properties.TryGetValue(key, out var value) ? value : null; - } -} diff --git a/src/Primitives/CrestApps.Core.AI/Models/MemorySettings.cs b/src/Primitives/CrestApps.Core.AI/Models/MemorySettings.cs deleted file mode 100644 index d155f1ea..00000000 --- a/src/Primitives/CrestApps.Core.AI/Models/MemorySettings.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CrestApps.Core.AI.Models; - -public sealed class MemorySettings -{ - public bool EnableUserMemory { get; set; } -} diff --git a/src/Primitives/CrestApps.Core.AI/Models/PromptTemplateMetadata.cs b/src/Primitives/CrestApps.Core.AI/Models/PromptTemplateMetadata.cs index 864fb22f..9b90c649 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/PromptTemplateMetadata.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/PromptTemplateMetadata.cs @@ -15,8 +15,8 @@ public void SetSelections(IEnumerable selections) { TemplateId = selection.TemplateId, Parameters = selection.Parameters is { Count: > 0 } - ? new Dictionary(selection.Parameters, StringComparer.OrdinalIgnoreCase) - : null, + ? new Dictionary(selection.Parameters, StringComparer.OrdinalIgnoreCase) + : null, }) .ToList() ?? []; } diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/AgentProxyTool.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/AgentProxyTool.cs index 8e6c855f..a1eeb31c 100644 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/AgentProxyTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/AgentProxyTool.cs @@ -65,7 +65,7 @@ protected override async ValueTask InvokeCoreAsync( try { var profileManager = arguments.Services.GetRequiredService(); - var profiles = await profileManager.GetAsync(AIProfileType.Agent); + var profiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); var agentProfile = profiles?.FirstOrDefault(p => string.Equals(p.Name, _agentProfileName, StringComparison.OrdinalIgnoreCase)); if (agentProfile is null) @@ -79,12 +79,12 @@ protected override async ValueTask InvokeCoreAsync( var contextBuilder = arguments.Services.GetRequiredService(); var deploymentManager = arguments.Services.GetRequiredService(); - var context = await contextBuilder.BuildAsync(agentProfile); + var context = await contextBuilder.BuildAsync(agentProfile, cancellationToken: cancellationToken); // Disable tools on the agent's context to prevent infinite recursion. context.DisableTools = true; - var deployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: context.ChatDeploymentName) + var deployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: context.ChatDeploymentName, cancellationToken: cancellationToken) ?? throw new InvalidOperationException($"Unable to resolve a chat deployment for agent profile '{_agentProfileName}'."); var messages = new List diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs index 945a6ef7..fc78396a 100644 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs @@ -27,7 +27,7 @@ public async Task> GetToolsAsync( AICompletionContext context, CancellationToken cancellationToken = default) { - var agents = await _profileManager.GetAsync(AIProfileType.Agent); + var agents = await _profileManager.GetAsync(AIProfileType.Agent, cancellationToken); var entries = new List(); if (agents is null) diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index a0e7a271..ee9edb89 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -22,7 +22,6 @@ using CrestApps.Core.Templates.Parsing; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -133,8 +132,6 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi // Ensure IHttpContextAccessor is available for services that need HTTP context. services.TryAddSingleton(); - services.TryAddSingleton(new ConfigurationBuilder().Build()); - services .AddCoreAITemplating() .AddCoreIndexingServices() @@ -162,6 +159,8 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddEnumerable(ServiceDescriptor.Scoped, ConfigurationAIProviderConnectionSource>()); services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); if (!services.Any(descriptor => descriptor.ServiceType == typeof(EmbeddedResourceAIProfileTemplateProvider))) { @@ -398,7 +397,7 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); - services.AddScoped(); + services.TryAddScoped(); 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 3c87548a..b874782a 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs @@ -1,6 +1,7 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; +using CrestApps.Core.Infrastructure; using Microsoft.Extensions.AI; namespace CrestApps.Core.AI.Services; @@ -23,7 +24,7 @@ public ValueTask GetChatClientAsync(AIProviderConnectionEntry conne { if (string.IsNullOrEmpty(deploymentName)) { - deploymentName = connection.GetLegacyChatDeploymentName(); + deploymentName = connection.GetStringValue("ChatDeploymentName", false); } if (string.IsNullOrEmpty(deploymentName)) @@ -40,7 +41,7 @@ public ValueTask>> GetEmbeddingGene { if (string.IsNullOrEmpty(deploymentName)) { - deploymentName = connection.GetLegacyEmbeddingDeploymentName(); + deploymentName = connection.GetStringValue("EmbeddingDeploymentName", false); } if (string.IsNullOrEmpty(deploymentName)) @@ -61,7 +62,7 @@ public ValueTask GetImageGeneratorAsync(AIProviderConnectionEnt { if (string.IsNullOrEmpty(deploymentName)) { - deploymentName = connection.GetLegacyImageDeploymentName(); + deploymentName = connection.GetStringValue("ImagesDeploymentName", false); } if (string.IsNullOrEmpty(deploymentName)) @@ -81,7 +82,7 @@ public ValueTask GetSpeechToTextClientAsync(AIProviderConne { if (string.IsNullOrEmpty(deploymentName)) { - deploymentName = connection.GetLegacySpeechToTextDeploymentName(); + deploymentName = connection.GetStringValue("SpeechToTextDeploymentName", false); } if (string.IsNullOrEmpty(deploymentName)) diff --git a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs index 49d83ea9..6b548890 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageTrackingChatClient.cs @@ -3,7 +3,7 @@ using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; -using CrestApps.Core.Infrastructure; +using CrestApps.Core.Extensions; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -112,7 +112,7 @@ private async Task RecordUsageAsync( responseLatencyMs, isStreaming); - await observers.InvokeHandlersAsync((observer, usageRecord) => observer.UsageRecordedAsync(usageRecord, cancellationToken), record, _logger); + await observers.InvokeAsync((observer, usageRecord) => observer.UsageRecordedAsync(usageRecord, cancellationToken), record, _logger); } private static AICompletionContext ResolveCompletionContext(ChatOptions options) diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs index f233b3bc..68d68961 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs @@ -94,7 +94,7 @@ private async Task AlignDataSourcesAsync(IServiceProvider services, Cancellation return; } - var dataSources = await dataSourceStore.GetAllAsync(); + var dataSources = await dataSourceStore.GetAllAsync(cancellationToken); var dataSourceList = dataSources?.ToList() ?? []; if (dataSourceList.Count == 0) { diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceSearchDocumentHandler.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceSearchDocumentHandler.cs index 0b4a1ea5..247d359e 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceSearchDocumentHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDataSourceSearchDocumentHandler.cs @@ -75,7 +75,7 @@ private async Task QueueSourceDocumentsAsync(IIndexProfileInfo profile, IReadOnl return; } - var sourceProfile = await indexProfileManager.FindByIdAsync(profile.IndexProfileId); + var sourceProfile = await indexProfileManager.FindByIdAsync(profile.IndexProfileId, cancellationToken); if (sourceProfile == null || string.IsNullOrWhiteSpace(sourceProfile.Name) || @@ -89,7 +89,7 @@ private async Task QueueSourceDocumentsAsync(IIndexProfileInfo profile, IReadOnl return; } - var dataSources = await dataSourceCatalog.GetAllAsync(); + var dataSources = await dataSourceCatalog.GetAllAsync(cancellationToken); if (!dataSources.Any(dataSource => string.Equals(dataSource.SourceIndexProfileName, sourceProfile.Name, StringComparison.OrdinalIgnoreCase) && diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentConnectionEntryFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentConnectionEntryFactory.cs index ae1d70dd..7445fc17 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentConnectionEntryFactory.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentConnectionEntryFactory.cs @@ -24,6 +24,7 @@ public static AIProviderConnectionEntry Create(AIDeployment deployment, IDataPro } UnprotectApiKeys(values, dataProtectionProvider); + AIProviderConnectionDeploymentNameNormalizer.Normalize(values); return new AIProviderConnectionEntry(values); } diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs index 80cca1b7..4b3c26a4 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIDeploymentManagerBase.cs @@ -15,49 +15,49 @@ public AIDeploymentManagerBase( { } - public async ValueTask> GetAllAsync(string clientName) + public async ValueTask> GetAllAsync(string clientName, CancellationToken cancellationToken = default) { - var deployments = (await Catalog.GetAllAsync()) + var deployments = (await Catalog.GetAllAsync(cancellationToken)) .Where(x => string.Equals(x.ClientName, clientName, StringComparison.OrdinalIgnoreCase)); foreach (var deployment in deployments) { - await LoadAsync(deployment); + await LoadAsync(deployment, cancellationToken); } return deployments; } - public async ValueTask> GetByTypeAsync(AIDeploymentType type) + public async ValueTask> GetByTypeAsync(AIDeploymentType type, CancellationToken cancellationToken = default) { - var deployments = (await Catalog.GetAllAsync()) + var deployments = (await Catalog.GetAllAsync(cancellationToken)) .Where(x => x.SupportsType(type)); foreach (var deployment in deployments) { - await LoadAsync(deployment); + await LoadAsync(deployment, cancellationToken); } return deployments; } - public async ValueTask GetDefaultAsync(string clientName, AIDeploymentType type) + public async ValueTask GetDefaultAsync(string clientName, AIDeploymentType type, CancellationToken cancellationToken = default) { - var deployments = await GetAllAsync(clientName); + var deployments = await GetAllAsync(clientName, cancellationToken); var candidates = deployments.Where(d => d.SupportsType(type)); return candidates.FirstOrDefault(); } - public ValueTask ResolveOrDefaultAsync(AIDeploymentType type, string deploymentName = null, string clientName = null) + public ValueTask ResolveOrDefaultAsync(AIDeploymentType type, string deploymentName = null, string clientName = null, CancellationToken cancellationToken = default) { - return ResolveByTypeAsync(type, deploymentName, clientName); + return ResolveByTypeAsync(type, deploymentName, clientName, cancellationToken); } - public async ValueTask> GetAllByTypeAsync(AIDeploymentType type, string clientName = null) + public async ValueTask> GetAllByTypeAsync(AIDeploymentType type, string clientName = null, CancellationToken cancellationToken = default) { - var allDeployments = await GetAllAsync(); + var allDeployments = await GetAllAsync(cancellationToken); var filtered = allDeployments.Where(d => d.SupportsType(type)); @@ -69,11 +69,11 @@ public async ValueTask> GetAllByTypeAsync(AIDeployment return filtered; } - private async ValueTask ResolveByTypeAsync(AIDeploymentType type, string deploymentName, string clientName) + private async ValueTask ResolveByTypeAsync(AIDeploymentType type, string deploymentName, string clientName, CancellationToken cancellationToken) { if (!string.IsNullOrEmpty(deploymentName)) { - var deployment = await FindBySelectorAsync(deploymentName); + var deployment = await FindBySelectorAsync(deploymentName, cancellationToken); if (deployment != null) { @@ -85,7 +85,7 @@ private async ValueTask ResolveByTypeAsync(AIDeploymentType type, if (!string.IsNullOrEmpty(globalDefaultId)) { - var deployment = await FindBySelectorAsync(globalDefaultId); + var deployment = await FindBySelectorAsync(globalDefaultId, cancellationToken); if (deployment != null) { @@ -93,12 +93,12 @@ private async ValueTask ResolveByTypeAsync(AIDeploymentType type, } } - return await GetFirstMatchingDeploymentAsync(type, clientName); + return await GetFirstMatchingDeploymentAsync(type, clientName, cancellationToken); } - private async ValueTask GetFirstMatchingDeploymentAsync(AIDeploymentType type, string clientName) + private async ValueTask GetFirstMatchingDeploymentAsync(AIDeploymentType type, string clientName, CancellationToken cancellationToken) { - var deployments = await GetAllAsync(); + var deployments = await GetAllAsync(cancellationToken); return deployments.FirstOrDefault(deployment => { @@ -117,16 +117,16 @@ private async ValueTask GetFirstMatchingDeploymentAsync(AIDeployme }); } - private async ValueTask FindBySelectorAsync(string selector) + private async ValueTask FindBySelectorAsync(string selector, CancellationToken cancellationToken) { - var deployment = await FindByIdAsync(selector); + var deployment = await FindByIdAsync(selector, cancellationToken); if (deployment != null) { return deployment; } - return await FindByNameAsync(selector); + return await FindByNameAsync(selector, cancellationToken); } private async ValueTask GetGlobalDefaultSelectorAsync(AIDeploymentType type) diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs index c18d924b..6efde7e7 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIMemoryIndexingService.cs @@ -147,7 +147,7 @@ public async Task SyncAsync(CancellationToken cancellationToken = default) return; } - var memories = await _memoryStore.GetAllAsync(); + var memories = await _memoryStore.GetAllAsync(cancellationToken); foreach (var memory in memories) { @@ -164,7 +164,7 @@ private async Task GetConfiguredIndexProfileAsync(Cancellati } cancellationToken.ThrowIfCancellationRequested(); - var indexProfile = await _indexProfileStore.FindByNameAsync(_memoryOptions.IndexProfileName); + var indexProfile = await _indexProfileStore.FindByNameAsync(_memoryOptions.IndexProfileName, cancellationToken); if (indexProfile is null) { diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs b/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs index 151b14b3..2fa9c931 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIMemorySearchService.cs @@ -66,7 +66,7 @@ public async Task> SearchAsync( return []; } - var indexProfile = await _indexProfileStore.FindByNameAsync(_memoryOptions.IndexProfileName); + var indexProfile = await _indexProfileStore.FindByNameAsync(_memoryOptions.IndexProfileName, cancellationToken); if (indexProfile is null || !string.Equals(indexProfile.Type, IndexProfileTypes.AIMemory, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs index 7c82df81..fa91939a 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs @@ -28,7 +28,7 @@ public AIProfileFileSystemTemplateProvider( _logger = logger; } - public Task> GetTemplatesAsync() + public Task> GetTemplatesAsync(CancellationToken cancellationToken = default) { var templates = new List(); var profilesDirectory = Path.Combine(_hostEnvironment.ContentRootPath, ProfilesDirectoryPath.Replace('/', Path.DirectorySeparatorChar)); diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionDeploymentNameNormalizer.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionDeploymentNameNormalizer.cs new file mode 100644 index 00000000..87387b24 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionDeploymentNameNormalizer.cs @@ -0,0 +1,56 @@ +using CrestApps.Core.Infrastructure; + +namespace CrestApps.Core.AI.Services; + +internal static class AIProviderConnectionDeploymentNameNormalizer +{ + public static void Normalize(IDictionary values) + { + ArgumentNullException.ThrowIfNull(values); + + Normalize(values, values, "ChatDeploymentName", "ChatDeploymentName", "DeploymentName", "DefaultChatDeploymentName", "DefaultDeploymentName"); + Normalize(values, values, "UtilityDeploymentName", "UtilityDeploymentName", "DefaultUtilityDeploymentName"); + Normalize(values, values, "EmbeddingDeploymentName", "EmbeddingDeploymentName", "DefaultEmbeddingDeploymentName"); + Normalize(values, values, "ImagesDeploymentName", "ImagesDeploymentName", "DefaultImagesDeploymentName"); + Normalize(values, values, "SpeechToTextDeploymentName", "SpeechToTextDeploymentName", "DefaultSpeechToTextDeploymentName"); + } + + public static void CopyNormalized(IDictionary source, IDictionary destination) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destination); + + Normalize(source, destination, "ChatDeploymentName", "ChatDeploymentName", "DeploymentName", "DefaultChatDeploymentName", "DefaultDeploymentName"); + Normalize(source, destination, "UtilityDeploymentName", "UtilityDeploymentName", "DefaultUtilityDeploymentName"); + Normalize(source, destination, "EmbeddingDeploymentName", "EmbeddingDeploymentName", "DefaultEmbeddingDeploymentName"); + Normalize(source, destination, "ImagesDeploymentName", "ImagesDeploymentName", "DefaultImagesDeploymentName"); + Normalize(source, destination, "SpeechToTextDeploymentName", "SpeechToTextDeploymentName", "DefaultSpeechToTextDeploymentName"); + } + + private static void Normalize(IDictionary source, IDictionary destination, string targetKey, params string[] sourceKeys) + { + var value = GetStringValue(source, sourceKeys); + + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + destination[targetKey] = value; + } + + private static string GetStringValue(IDictionary values, params string[] keys) + { + foreach (var key in keys) + { + var value = values.GetStringValue(key, false); + + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs index d22e060d..b955ce98 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs @@ -44,6 +44,8 @@ public static AIProviderConnectionEntry Create(AIProviderConnection connection, } } + AIProviderConnectionDeploymentNameNormalizer.Normalize(values); + values["DisplayText"] = string.IsNullOrWhiteSpace(connection.DisplayText) ? connection.Name : connection.DisplayText; diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs index 2f47182b..ffc61307 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs @@ -38,7 +38,7 @@ public ConfigurationAIDeploymentSource( public int Order => 100; - public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default) { var deployments = new Dictionary(StringComparer.OrdinalIgnoreCase); var names = knownEntries diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs index 65df7547..e8777025 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs @@ -34,7 +34,7 @@ public ConfigurationAIProviderConnectionSource( public int Order => 100; - public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries, CancellationToken cancellationToken = default) { var connections = new Dictionary(StringComparer.OrdinalIgnoreCase); var names = knownEntries @@ -216,6 +216,8 @@ private AIProviderConnection ParseConnection( .Where(static pair => !IsConnectionMetadataKey(pair.Key)) .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); + AIProviderConnectionDeploymentNameNormalizer.CopyNormalized(values, properties); + return new AIProviderConnection { ItemId = AIConfigurationRecordIds.CreateConnectionId(clientName, connectionName), @@ -236,12 +238,17 @@ private static bool IsConnectionMetadataKey(string key) string.Equals(key, "DisplayText", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "ConnectionNameAlias", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "ChatDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DeploymentName", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "DefaultChatDeploymentName", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "DefaultDeploymentName", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "EmbeddingDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DefaultEmbeddingDeploymentName", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "ImagesDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DefaultImagesDeploymentName", StringComparison.OrdinalIgnoreCase) || string.Equals(key, "UtilityDeploymentName", StringComparison.OrdinalIgnoreCase) || - string.Equals(key, "SpeechToTextDeploymentName", StringComparison.OrdinalIgnoreCase); + string.Equals(key, "DefaultUtilityDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "SpeechToTextDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DefaultSpeechToTextDeploymentName", StringComparison.OrdinalIgnoreCase); } private static Dictionary ReadObject(IConfigurationSection section) diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs index 3422f0ca..3d97b5c1 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs @@ -40,25 +40,16 @@ public async ValueTask CreateChatClientAsync(AIDeployment deploymen var connection = await GetConnectionEntryAsync(deployment); - foreach (var clientProvider in _clientProviders) - { - if (!clientProvider.CanHandle(deployment.ClientName)) - { - continue; - } - - var client = await clientProvider.GetChatClientAsync(connection, deployment.ModelName); - - return new AICompletionUsageTrackingChatClient( - client, - deployment.ClientName, - deployment.ConnectionName, - deployment.ModelName, - _serviceProvider, - _logger); - } - - throw new ArgumentException($"Unable to find an implementation of '{nameof(IAIClientProvider)}' that can handle the client '{deployment.ClientName}'."); + var client = await ResolveClientAsync(deployment, connection, + (provider, conn, model) => provider.GetChatClientAsync(conn, model)); + + return new AICompletionUsageTrackingChatClient( + client, + deployment.ClientName, + deployment.ConnectionName, + deployment.ModelName, + _serviceProvider, + _logger); } public async ValueTask>> CreateEmbeddingGeneratorAsync(AIDeployment deployment) @@ -68,17 +59,8 @@ public async ValueTask>> CreateEmbe var connection = await GetConnectionEntryAsync(deployment); - foreach (var clientProvider in _clientProviders) - { - if (!clientProvider.CanHandle(deployment.ClientName)) - { - continue; - } - - return await clientProvider.GetEmbeddingGeneratorAsync(connection, deployment.ModelName); - } - - throw new ArgumentException($"Unable to find an implementation of '{nameof(IAIClientProvider)}' that can handle the client '{deployment.ClientName}'."); + return await ResolveClientAsync(deployment, connection, + (provider, conn, model) => provider.GetEmbeddingGeneratorAsync(conn, model)); } #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. @@ -90,17 +72,8 @@ public async ValueTask CreateImageGeneratorAsync(AIDeployment d var connection = await GetConnectionEntryAsync(deployment); - foreach (var clientProvider in _clientProviders) - { - if (!clientProvider.CanHandle(deployment.ClientName)) - { - continue; - } - - return await clientProvider.GetImageGeneratorAsync(connection, deployment.ModelName); - } - - throw new ArgumentException($"Unable to find an implementation of '{nameof(IAIClientProvider)}' that can handle the client '{deployment.ClientName}'."); + return await ResolveClientAsync(deployment, connection, + (provider, conn, model) => provider.GetImageGeneratorAsync(conn, model)); } #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. @@ -112,17 +85,8 @@ public async ValueTask CreateSpeechToTextClientAsync(AIDepl var connection = await GetConnectionEntryAsync(deployment); - foreach (var clientProvider in _clientProviders) - { - if (!clientProvider.CanHandle(deployment.ClientName)) - { - continue; - } - - return await clientProvider.GetSpeechToTextClientAsync(connection, deployment.ModelName); - } - - throw new ArgumentException($"Unable to find an implementation of '{nameof(IAIClientProvider)}' that can handle the client '{deployment.ClientName}'."); + return await ResolveClientAsync(deployment, connection, + (provider, conn, model) => provider.GetSpeechToTextClientAsync(conn, model)); } #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. @@ -134,6 +98,18 @@ public async ValueTask CreateTextToSpeechClientAsync(AIDepl var connection = await GetConnectionEntryAsync(deployment); + return await ResolveClientAsync(deployment, connection, + (provider, conn, model) => provider.GetTextToSpeechClientAsync(conn, model)); + } + + /// + /// Resolves a client from the first registered provider that can handle the deployment's client name. + /// + private async ValueTask ResolveClientAsync( + AIDeployment deployment, + AIProviderConnectionEntry connection, + Func> factory) + { foreach (var clientProvider in _clientProviders) { if (!clientProvider.CanHandle(deployment.ClientName)) @@ -141,7 +117,7 @@ public async ValueTask CreateTextToSpeechClientAsync(AIDepl continue; } - return await clientProvider.GetTextToSpeechClientAsync(connection, deployment.ModelName); + return await factory(clientProvider, connection, deployment.ModelName); } throw new ArgumentException($"Unable to find an implementation of '{nameof(IAIClientProvider)}' that can handle the client '{deployment.ClientName}'."); diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionContextBuilder.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionContextBuilder.cs index 62c032d9..1199086c 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionContextBuilder.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionContextBuilder.cs @@ -21,7 +21,7 @@ public DefaultAICompletionContextBuilder( _logger = logger; } - public async ValueTask BuildAsync(object resource, Action configure = null) + public async ValueTask BuildAsync(object resource, Action configure = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(resource); diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs index ed807197..d1e61115 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs @@ -48,7 +48,7 @@ public DefaultAIDataSourceIndexingService( public async Task SyncAllAsync(CancellationToken cancellationToken = default) { - var dataSources = await _dataSourceCatalog.GetAllAsync(); + var dataSources = await _dataSourceCatalog.GetAllAsync(cancellationToken); foreach (var dataSource in dataSources) { cancellationToken.ThrowIfCancellationRequested(); @@ -341,12 +341,13 @@ private async Task> GetMatchingDataSourcesAsyn .ToArray(); } - private static List BuildChunkIds(IEnumerable referenceIds) + private static List BuildChunkIds(IEnumerable referenceIds, int maxChunksPerDocument = MaxChunkIdsPerDocument) { var chunkIds = new List(); + foreach (var referenceId in referenceIds) { - for (var i = 0; i < MaxChunkIdsPerDocument; i++) + for (var i = 0; i < maxChunksPerDocument; i++) { chunkIds.Add($"{referenceId}_{i}"); } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProfileTemplateManager.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProfileTemplateManager.cs index 594aad77..b616aee4 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProfileTemplateManager.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProfileTemplateManager.cs @@ -24,19 +24,18 @@ public DefaultAIProfileTemplateManager( _providers = providers; } - public new async ValueTask> GetAllAsync() + public new async ValueTask> GetAllAsync(CancellationToken cancellationToken = default) { - var dbTemplates = await base.GetAllAsync(); + var dbTemplates = await base.GetAllAsync(cancellationToken); return await MergeWithProvidersAsync(dbTemplates); } - public new async ValueTask FindByIdAsync(string id) + public new async ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(id); - var template = await base.FindByIdAsync(id); - + var template = await base.FindByIdAsync(id, cancellationToken); if (template is not null) { return template; @@ -44,7 +43,7 @@ public DefaultAIProfileTemplateManager( foreach (var provider in _providers) { - var templates = await provider.GetTemplatesAsync(); + var templates = await provider.GetTemplatesAsync(cancellationToken); template = templates.FirstOrDefault(t => string.Equals(t.ItemId, id, StringComparison.OrdinalIgnoreCase)); if (template is not null) @@ -56,27 +55,27 @@ public DefaultAIProfileTemplateManager( return null; } - public new async ValueTask> GetAsync(string source) + public new async ValueTask> GetAsync(string source, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(source); - var dbTemplates = await base.GetAsync(source); + var dbTemplates = await base.GetAsync(source, cancellationToken); return await MergeWithProvidersAsync(dbTemplates, source); } - public new async ValueTask> FindBySourceAsync(string source) + public new async ValueTask> FindBySourceAsync(string source, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(source); - var dbTemplates = await base.FindBySourceAsync(source); + var dbTemplates = await base.FindBySourceAsync(source, cancellationToken); return await MergeWithProvidersAsync(dbTemplates, source); } - public async ValueTask> GetListableAsync() + public async ValueTask> GetListableAsync(CancellationToken cancellationToken = default) { - var templates = await GetAllAsync(); + var templates = await GetAllAsync(cancellationToken); return templates.Where(template => template.IsListable); } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultConnectionAuthHeaderBuilder.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultConnectionAuthHeaderBuilder.cs new file mode 100644 index 00000000..a3ea90d0 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultConnectionAuthHeaderBuilder.cs @@ -0,0 +1,186 @@ +using System.Text; +using CrestApps.Core.AI.Models; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Services; + +internal sealed class DefaultConnectionAuthHeaderBuilder : IConnectionAuthHeaderBuilder +{ + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IOAuth2TokenService _oauth2TokenService; + private readonly ILogger _logger; + + public DefaultConnectionAuthHeaderBuilder( + IDataProtectionProvider dataProtectionProvider, + IOAuth2TokenService oauth2TokenService, + ILogger logger) + { + _dataProtectionProvider = dataProtectionProvider; + _oauth2TokenService = oauth2TokenService; + _logger = logger; + } + + public async Task> BuildHeadersAsync( + IConnectionAuthMetadata metadata, + string dataProtectionPurpose, + CancellationToken cancellationToken = default) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (metadata is null) + { + return headers; + } + + var protector = _dataProtectionProvider.CreateProtector(dataProtectionPurpose); + + switch (metadata.AuthenticationType) + { + case ClientAuthenticationType.ApiKey: + BuildApiKeyHeaders(metadata, protector, headers); + break; + + case ClientAuthenticationType.Basic: + BuildBasicHeaders(metadata, protector, headers); + break; + + case ClientAuthenticationType.OAuth2ClientCredentials: + await BuildOAuth2ClientCredentialsHeadersAsync(metadata, protector, headers, cancellationToken); + break; + + case ClientAuthenticationType.OAuth2PrivateKeyJwt: + await BuildOAuth2PrivateKeyJwtHeadersAsync(metadata, protector, headers, cancellationToken); + break; + + case ClientAuthenticationType.OAuth2Mtls: + await BuildOAuth2MtlsHeadersAsync(metadata, protector, headers, cancellationToken); + break; + + case ClientAuthenticationType.CustomHeaders: + BuildCustomHeaders(metadata, headers); + break; + } + + return headers; + } + + private void BuildApiKeyHeaders(IConnectionAuthMetadata metadata, IDataProtector protector, Dictionary headers) + { + if (string.IsNullOrEmpty(metadata.ApiKey)) + { + return; + } + + var apiKey = DataProtectionHelper.Unprotect(protector, metadata.ApiKey, _logger, "Failed to unprotect API key credential."); + var headerName = string.IsNullOrWhiteSpace(metadata.ApiKeyHeaderName) ? "Authorization" : metadata.ApiKeyHeaderName; + headers[headerName] = !string.IsNullOrWhiteSpace(metadata.ApiKeyPrefix) ? $"{metadata.ApiKeyPrefix} {apiKey}" : apiKey; + } + + private void BuildBasicHeaders(IConnectionAuthMetadata metadata, IDataProtector protector, Dictionary headers) + { + if (string.IsNullOrEmpty(metadata.BasicUsername)) + { + return; + } + + var password = !string.IsNullOrEmpty(metadata.BasicPassword) + ? DataProtectionHelper.Unprotect(protector, metadata.BasicPassword, _logger, "Failed to unprotect Basic password credential.") + : string.Empty; + + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{metadata.BasicUsername}:{password}")); + headers["Authorization"] = $"Basic {credentials}"; + } + + private async Task BuildOAuth2ClientCredentialsHeadersAsync( + IConnectionAuthMetadata metadata, + IDataProtector protector, + Dictionary headers, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) || string.IsNullOrEmpty(metadata.OAuth2ClientId) || string.IsNullOrEmpty(metadata.OAuth2ClientSecret)) + { + return; + } + + var clientSecret = DataProtectionHelper.Unprotect(protector, metadata.OAuth2ClientSecret, _logger, "Failed to unprotect OAuth2 client secret."); + + try + { + var token = await _oauth2TokenService.AcquireTokenAsync(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, clientSecret, metadata.OAuth2Scopes, cancellationToken); + headers["Authorization"] = $"Bearer {token}"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire OAuth2 token from '{TokenEndpoint}'.", metadata.OAuth2TokenEndpoint); + throw; + } + } + + private async Task BuildOAuth2PrivateKeyJwtHeadersAsync( + IConnectionAuthMetadata metadata, + IDataProtector protector, + Dictionary headers, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) || string.IsNullOrEmpty(metadata.OAuth2ClientId) || string.IsNullOrEmpty(metadata.OAuth2PrivateKey)) + { + return; + } + + var privateKey = DataProtectionHelper.Unprotect(protector, metadata.OAuth2PrivateKey, _logger, "Failed to unprotect OAuth2 private key."); + + try + { + var token = await _oauth2TokenService.AcquireTokenWithPrivateKeyJwtAsync(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, privateKey, metadata.OAuth2KeyId, metadata.OAuth2Scopes, cancellationToken); + headers["Authorization"] = $"Bearer {token}"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire OAuth2 token via Private Key JWT from '{TokenEndpoint}'.", metadata.OAuth2TokenEndpoint); + throw; + } + } + + private async Task BuildOAuth2MtlsHeadersAsync( + IConnectionAuthMetadata metadata, + IDataProtector protector, + Dictionary headers, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(metadata.OAuth2TokenEndpoint) || string.IsNullOrEmpty(metadata.OAuth2ClientId) || string.IsNullOrEmpty(metadata.OAuth2ClientCertificate)) + { + return; + } + + var certBase64 = DataProtectionHelper.Unprotect(protector, metadata.OAuth2ClientCertificate, _logger, "Failed to unprotect OAuth2 client certificate."); + var certBytes = Convert.FromBase64String(certBase64); + var certPassword = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificatePassword) + ? DataProtectionHelper.Unprotect(protector, metadata.OAuth2ClientCertificatePassword, _logger, "Failed to unprotect OAuth2 certificate password.") + : null; + + try + { + var token = await _oauth2TokenService.AcquireTokenWithMtlsAsync(metadata.OAuth2TokenEndpoint, metadata.OAuth2ClientId, certBytes, certPassword, metadata.OAuth2Scopes, cancellationToken); + headers["Authorization"] = $"Bearer {token}"; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire OAuth2 token via mTLS from '{TokenEndpoint}'.", metadata.OAuth2TokenEndpoint); + throw; + } + } + + private static void BuildCustomHeaders(IConnectionAuthMetadata metadata, Dictionary headers) + { + if (metadata.AdditionalHeaders is null) + { + return; + } + + foreach (var header in metadata.AdditionalHeaders) + { + headers[header.Key] = header.Value; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultOAuth2TokenService.cs similarity index 96% rename from src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs rename to src/Primitives/CrestApps.Core.AI/Services/DefaultOAuth2TokenService.cs index 4d7b9760..53389183 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultOAuth2TokenService.cs @@ -7,8 +7,12 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -namespace CrestApps.Core.AI.Mcp.Services; +namespace CrestApps.Core.AI.Services; +/// +/// Default implementation of that acquires OAuth 2.0 tokens +/// using client credentials, private key JWT, or mTLS client authentication. +/// public sealed class DefaultOAuth2TokenService : IOAuth2TokenService { private const int ExpirationBufferSeconds = 60; @@ -54,6 +58,7 @@ public async Task AcquireTokenAsync(string tokenEndpoint, string clientI } using var httpClient = _httpClientFactory.CreateClient(nameof(DefaultOAuth2TokenService)); + return await SendTokenRequestAsync(httpClient, tokenEndpoint, parameters, cacheKey, cancellationToken); } @@ -83,6 +88,7 @@ public async Task AcquireTokenWithPrivateKeyJwtAsync(string tokenEndpoin } using var httpClient = _httpClientFactory.CreateClient(nameof(DefaultOAuth2TokenService)); + return await SendTokenRequestAsync(httpClient, tokenEndpoint, parameters, cacheKey, cancellationToken); } @@ -114,6 +120,7 @@ public async Task AcquireTokenWithMtlsAsync(string tokenEndpoint, string var handler = new HttpClientHandler(); handler.ClientCertificates.Add(certificate); using var httpClient = new HttpClient(handler); + return await SendTokenRequestAsync(httpClient, tokenEndpoint, parameters, cacheKey, cancellationToken); } } @@ -140,6 +147,7 @@ private async Task SendTokenRequestAsync(HttpClient httpClient, string t var expiration = tokenResponse.ExpiresIn > ExpirationBufferSeconds ? TimeSpan.FromSeconds(tokenResponse.ExpiresIn - ExpirationBufferSeconds) : TimeSpan.FromMinutes(5); _cache.Set(cacheKey, tokenResponse.AccessToken, expiration); + return tokenResponse.AccessToken; } @@ -171,6 +179,7 @@ private string CreateClientAssertion(string tokenEndpoint, string clientId, stri using var rsa = RSA.Create(); rsa.ImportFromPem(privateKeyPem); var signature = rsa.SignData(dataToSign, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + return $"{headerBase64}.{payloadBase64}.{Base64UrlEncode(signature)}"; } @@ -181,7 +190,7 @@ private static string Base64UrlEncode(byte[] input) private static string GetCacheKey(string grantType, string tokenEndpoint, string clientId, string scopes) { - return $"mcp_oauth2_{grantType}_{tokenEndpoint}_{clientId}_{scopes}"; + return $"oauth2_{grantType}_{tokenEndpoint}_{clientId}_{scopes}"; } private sealed class OAuth2TokenResponse diff --git a/src/Primitives/CrestApps.Core.AI/Services/EmbeddedResourceAIProfileTemplateProvider.cs b/src/Primitives/CrestApps.Core.AI/Services/EmbeddedResourceAIProfileTemplateProvider.cs index fb7a5361..30ef6f5a 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/EmbeddedResourceAIProfileTemplateProvider.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/EmbeddedResourceAIProfileTemplateProvider.cs @@ -22,7 +22,7 @@ public EmbeddedResourceAIProfileTemplateProvider(Assembly assembly, IEnumerable< _parsers = parsers; } - public Task> GetTemplatesAsync() + public Task> GetTemplatesAsync(CancellationToken cancellationToken = default) { var templates = new List(); diff --git a/src/Primitives/CrestApps.Core.AI/Services/IConnectionAuthHeaderBuilder.cs b/src/Primitives/CrestApps.Core.AI/Services/IConnectionAuthHeaderBuilder.cs new file mode 100644 index 00000000..55127229 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/IConnectionAuthHeaderBuilder.cs @@ -0,0 +1,22 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Services; + +/// +/// Builds HTTP authentication headers from connection metadata. +/// Protocol-agnostic — works for MCP SSE, A2A, or any future protocol. +/// +public interface IConnectionAuthHeaderBuilder +{ + /// + /// Builds a dictionary of HTTP authentication headers based on the provided metadata. + /// + /// The connection authentication metadata. + /// The data protection purpose string for unprotecting credentials. + /// A token to cancel the operation. + /// A dictionary of HTTP header name-value pairs. + Task> BuildHeadersAsync( + IConnectionAuthMetadata metadata, + string dataProtectionPurpose, + CancellationToken cancellationToken = default); +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/IOAuth2TokenService.cs b/src/Primitives/CrestApps.Core.AI/Services/IOAuth2TokenService.cs similarity index 98% rename from src/Primitives/CrestApps.Core.AI.Mcp/Services/IOAuth2TokenService.cs rename to src/Primitives/CrestApps.Core.AI/Services/IOAuth2TokenService.cs index 8b311724..38a57156 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/IOAuth2TokenService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/IOAuth2TokenService.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Mcp.Services; +namespace CrestApps.Core.AI.Services; /// /// Acquires OAuth 2.0 access tokens using various client authentication methods, diff --git a/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs index 57767fcc..56be9c49 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs @@ -4,7 +4,7 @@ using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; -using CrestApps.Core.Infrastructure; +using CrestApps.Core.Extensions; using CrestApps.Core.Templates.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.Caching.Distributed; @@ -181,15 +181,17 @@ private static List GetPrompts(IEnumerable messages, A prompts.Add(new ChatMessage(ChatRole.System, systemMessage)); } + var materializedMessages = chatMessages.ToList(); + if (context.PastMessagesCount > 1) { - var skip = GetTotalMessagesToSkip(chatMessages.Count(), context.PastMessagesCount.Value); + var skip = GetTotalMessagesToSkip(materializedMessages.Count, context.PastMessagesCount.Value); - prompts.AddRange(chatMessages.Skip(skip).Take(context.PastMessagesCount.Value)); + prompts.AddRange(materializedMessages.Skip(skip).Take(context.PastMessagesCount.Value)); } else { - prompts.AddRange(chatMessages); + prompts.AddRange(materializedMessages); } return prompts; @@ -212,11 +214,10 @@ private async Task GetChatOptionsAsync(AICompletionContext context, { DeploymentName = deploymentName, ClientName = ClientName, - ImplemenationName = ClientName, IsStreaming = isStreaming, }; - await _handlers.InvokeHandlersAsync((handler, ctx) => handler.ConfigureAsync(ctx), configureContext, Logger); + await _handlers.InvokeAsync((handler, ctx) => handler.ConfigureAsync(ctx), configureContext, Logger); if (!supportFunctions || (chatOptions.Tools is not null && chatOptions.Tools.Count == 0)) { diff --git a/src/Primitives/CrestApps.Core.AI/Services/ProviderAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI/Services/ProviderAICompletionClient.cs new file mode 100644 index 00000000..14ad2bf1 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/ProviderAICompletionClient.cs @@ -0,0 +1,35 @@ +using CrestApps.Core.AI.Clients; +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Templates.Services; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Services; + +/// +/// A generic AI completion client that derives its client name from a +/// marker type, eliminating the need for +/// provider-specific subclass files. +/// +/// +/// A marker type implementing that supplies the client name. +/// +public sealed class ProviderAICompletionClient : NamedAICompletionClient + where TProvider : IAIClientMarker +{ + public ProviderAICompletionClient( + IAIClientFactory aIClientFactory, + ILoggerFactory loggerFactory, + IDistributedCache distributedCache, + IServiceProvider serviceProvider, + IEnumerable handlers, + IOptions defaultOptions, + ITemplateService aiTemplateService, + IAIDeploymentManager deploymentManager) + : base(TProvider.ClientName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) + { + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-availability.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-availability.md index 8d63032c..f579efcb 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-availability.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-availability.md @@ -14,7 +14,7 @@ Category: Documents {% if userSuppliedDocuments.size > 0 %} {% if tools.size > 0 %} The user has uploaded the following documents as supplementary context. -Search the uploaded documents first using the document tools before answering. +Use the document tools before answering: prefer semantic search for targeted lookups, and read a full document when the task requires whole-file context such as summarizing, reviewing, rewriting, translating, or extracting complete information from an uploaded file. {% if isInScope %} Answer only from the uploaded documents and retrieved document context. If the documents do not contain the answer, clearly say that the answer is not available in the uploaded documents. diff --git a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-context-header.md b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-context-header.md index 5dbd11b5..31c64876 100644 --- a/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-context-header.md +++ b/src/Primitives/CrestApps.Core.AI/Templates/Prompts/document-context-header.md @@ -5,13 +5,19 @@ Parameters: - searchToolName: the name of the search tool for additional lookups (optional). - hasUserSuppliedDocumentContext: boolean flag indicating user-uploaded/session document context is present. - hasKnowledgeBaseDocumentContext: boolean flag indicating profile knowledge-base document context is present. + - hasFullUserDocumentContext: boolean flag indicating full uploaded-document content was injected because the task needs whole-file context. IsListable: false Category: RAG --- {% if hasUserSuppliedDocumentContext %} [Uploaded Document Context] +{% if hasFullUserDocumentContext %} +The following content includes the full text of the user's uploaded documents because the current task depends on whole-document context. +Use this content directly when answering. +{% else %} The following content was retrieved from the user's uploaded documents via semantic search. Use this information to answer the user's question accurately. +{% endif %} If the documents do not contain relevant information, use your general knowledge to answer instead. When citing information, include the corresponding reference marker (e.g., [doc:1]) inline in your response immediately after the relevant statement. {% if searchToolName %} diff --git a/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs index dfb7b56f..645059ab 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/DataSourceSearchTool.cs @@ -83,7 +83,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } var dataSourceStore = arguments.Services.GetRequiredService>(); - var dataSource = await dataSourceStore.FindByIdAsync(dataSourceId); + var dataSource = await dataSourceStore.FindByIdAsync(dataSourceId, cancellationToken); if (dataSource == null) { @@ -99,7 +99,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } var indexProfileStore = arguments.Services.GetRequiredService(); - var masterIndexProfile = await indexProfileStore.FindByNameAsync(dataSource.AIKnowledgeBaseIndexProfileName); + var masterIndexProfile = await indexProfileStore.FindByNameAsync(dataSource.AIKnowledgeBaseIndexProfileName, cancellationToken); if (masterIndexProfile == null) { diff --git a/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs index 2e657d14..f4e9e0d3 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs @@ -4,8 +4,8 @@ using CrestApps.Core.AI.Extensions; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Support.Json; using CrestApps.Core.Templates.Services; +using CrestApps.Core.Support.Json; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/Primitives/CrestApps.Core.AI/Tools/GenerateImageTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/GenerateImageTool.cs index a1a05a10..60180628 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/GenerateImageTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/GenerateImageTool.cs @@ -78,7 +78,7 @@ protected override async ValueTask InvokeCoreAsync( var clientName = executionContext.ClientName; var deploymentManager = arguments.Services.GetRequiredService(); - var deployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Image, clientName); + var deployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Image, clientName, cancellationToken: cancellationToken); if (deployment == null) { diff --git a/src/Primitives/CrestApps.Core.AI/Tools/RemoveUserMemoryTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/RemoveUserMemoryTool.cs index 1f76e217..ca54d9c7 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/RemoveUserMemoryTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/RemoveUserMemoryTool.cs @@ -76,7 +76,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a return "No saved memory was found with that name."; } - await manager.DeleteAsync(existingMemory); + await manager.DeleteAsync(existingMemory, cancellationToken); return JsonSerializer.Serialize(new { diff --git a/src/Primitives/CrestApps.Core.AI/Tools/SaveUserMemoryTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/SaveUserMemoryTool.cs index 5d9ef729..8991b17f 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/SaveUserMemoryTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/SaveUserMemoryTool.cs @@ -117,7 +117,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a UpdatedUtc = utcNow, }; - await manager.CreateAsync(existingMemory); + await manager.CreateAsync(existingMemory, cancellationToken); } else { @@ -125,7 +125,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a existingMemory.Description = description; existingMemory.Content = content; existingMemory.UpdatedUtc = utcNow; - await manager.UpdateAsync(existingMemory); + await manager.UpdateAsync(existingMemory, cancellationToken: cancellationToken); } return JsonSerializer.Serialize(new diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs index 2fda2741..ad997e0a 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs @@ -1,5 +1,3 @@ -using Azure; -using Azure.Identity; using Azure.Search.Documents.Indexes; using CrestApps.Core.Azure.AISearch.Builders; using CrestApps.Core.Azure.AISearch.Services; @@ -22,20 +20,6 @@ public static IServiceCollection AddCoreAzureAISearchServices(this IServiceColle ArgumentNullException.ThrowIfNull(configuration); services.Configure(configuration); - var options = new AzureAISearchConnectionOptions(); - configuration.Bind(options); - if (!string.IsNullOrEmpty(options.Endpoint)) - { - var endpoint = new Uri(options.Endpoint); - if (!string.IsNullOrEmpty(options.ApiKey)) - { - services.TryAddSingleton(new SearchIndexClient(endpoint, new AzureKeyCredential(options.ApiKey))); - } - else - { - services.TryAddSingleton(new SearchIndexClient(endpoint, new DefaultAzureCredential())); - } - } return services.AddCoreAzureAISearchServices(); } @@ -44,6 +28,10 @@ public static IServiceCollection AddCoreAzureAISearchServices(this IServiceColle { ArgumentNullException.ThrowIfNull(services); + services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService().CreateSearchIndexClient()); + services.TryAddKeyedScoped(AISearchConstants.ProviderName, (sp, _) => new AzureAISearchDataSourceContentManager(sp.GetRequiredService(), sp.GetRequiredService>())); diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs new file mode 100644 index 00000000..78cc5792 --- /dev/null +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchClientFactory.cs @@ -0,0 +1,71 @@ +using System.Collections.Concurrent; +using Azure; +using Azure.Identity; +using Azure.Search.Documents; +using Azure.Search.Documents.Indexes; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Azure.AISearch.Services; + +/// +/// Creates Azure AI Search clients from the current connection options. +/// +public sealed class AzureAISearchClientFactory : IAzureAISearchClientFactory +{ + private readonly AzureAISearchConnectionOptions _options; + private readonly object _syncLock = new(); + private readonly ConcurrentDictionary _clients = new(StringComparer.Ordinal); + + private SearchIndexClient _searchIndexClient; + + public AzureAISearchClientFactory(IOptions options) + { + _options = options.Value; + } + + public SearchIndexClient CreateSearchIndexClient() + { + if (_searchIndexClient != null) + { + return _searchIndexClient; + } + + lock (_syncLock) + { + _searchIndexClient ??= CreateSearchIndexClient(_options); + } + + return _searchIndexClient; + } + + public SearchClient CreateSearchClient(string indexFullName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(indexFullName); + + var normalizedIndexFullName = indexFullName.Trim(); + + return _clients.GetOrAdd(normalizedIndexFullName, static (name, factory) => + { + return factory.CreateSearchIndexClient().GetSearchClient(name); + }, this); + } + + private static SearchIndexClient CreateSearchIndexClient(AzureAISearchConnectionOptions configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + if (string.IsNullOrWhiteSpace(configuration.Endpoint)) + { + throw new InvalidOperationException("Azure AI Search is not configured."); + } + + if (!Uri.TryCreate(configuration.Endpoint.Trim(), UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException("The Azure AI Search endpoint is invalid."); + } + + return !string.IsNullOrWhiteSpace(configuration.ApiKey) + ? new SearchIndexClient(endpoint, new AzureKeyCredential(configuration.ApiKey.Trim())) + : new SearchIndexClient(endpoint, new DefaultAzureCredential()); + } +} diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDataSourceContentManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDataSourceContentManager.cs index 6fd06992..9d968746 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDataSourceContentManager.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDataSourceContentManager.cs @@ -22,7 +22,7 @@ internal sealed class AzureAISearchDataSourceContentManager : IDataSourceContent internal static string BuildODataFilter(string dataSourceId, string filter) { // Always filter by dataSourceId. - var odataFilter = $"{DataSourceConstants.ColumnNames.DataSourceId} eq '{dataSourceId}'"; + var odataFilter = $"{DataSourceConstants.ColumnNames.DataSourceId} eq '{SanitizeODataValue(dataSourceId)}'"; // Merge with user-provided filter (already translated to OData for Azure). @@ -182,7 +182,7 @@ public async Task DeleteByDataSourceIdAsync( { var searchClient = _searchIndexClient.GetSearchClient(indexProfile.IndexFullName); - var odataFilter = $"{DataSourceConstants.ColumnNames.DataSourceId} eq '{dataSourceId}'"; + var odataFilter = $"{DataSourceConstants.ColumnNames.DataSourceId} eq '{SanitizeODataValue(dataSourceId)}'"; long totalDeleted = 0; @@ -251,4 +251,9 @@ public async Task DeleteByDataSourceIdAsync( return 0; } } + + private static string SanitizeODataValue(string value) + { + return value.Replace("'", "''"); + } } diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs index b5ed852f..b7fd3ba3 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs @@ -4,6 +4,7 @@ using Azure.Search.Documents.Models; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Support; using Microsoft.Extensions.Logging; using AzureSearchDocument = Azure.Search.Documents.Models.SearchDocument; @@ -29,11 +30,6 @@ public AzureAISearchDocumentManager( _logger = logger; } - private static string SanitizeLogValue(string value) - { - return value?.Replace("\r", string.Empty).Replace("\n", string.Empty) ?? string.Empty; - } - public async Task AddOrUpdateAsync(IIndexProfileInfo profile, IReadOnlyCollection documents, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); @@ -67,12 +63,12 @@ public async Task AddOrUpdateAsync(IIndexProfileInfo profile, IReadOnlyCol } catch (RequestFailedException ex) { - _logger.LogError(ex, "Azure AI Search index documents failed for index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Azure AI Search index documents failed for index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); return false; } catch (Exception ex) { - _logger.LogError(ex, "Error indexing documents in Azure AI Search index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error indexing documents in Azure AI Search index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); return false; } } @@ -99,11 +95,11 @@ public async Task DeleteAsync(IIndexProfileInfo profile, IEnumerable doc } catch (RequestFailedException ex) { - _logger.LogError(ex, "Azure AI Search delete failed for index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Azure AI Search delete failed for index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } catch (Exception ex) { - _logger.LogError(ex, "Error deleting documents from Azure AI Search index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error deleting documents from Azure AI Search index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } } @@ -149,11 +145,11 @@ public async Task DeleteAllAsync(IIndexProfileInfo profile, CancellationToken ca } catch (RequestFailedException ex) { - _logger.LogError(ex, "Azure AI Search delete all failed for index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Azure AI Search delete all failed for index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } catch (Exception ex) { - _logger.LogError(ex, "Error deleting all documents from Azure AI Search index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error deleting all documents from Azure AI Search index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } } @@ -170,7 +166,7 @@ private async Task GetKeyFieldNameAsync(string indexFullName, Cancellati } catch (Exception ex) { - _logger.LogWarning(ex, "Unable to determine key field for index '{IndexName}', defaulting to 'id'.", SanitizeLogValue(indexFullName)); + _logger.LogWarning(ex, "Unable to determine key field for index '{IndexName}', defaulting to 'id'.", indexFullName.SanitizeForLog()); } return "id"; @@ -197,7 +193,7 @@ private async Task NotifyDocumentsAddedOrUpdatedAsync(IIndexProfileInfo profile, if (_logger.IsEnabled(LogLevel.Trace)) { - _logger.LogTrace("Notifying {HandlerCount} search document handler(s) after add/update for index '{IndexName}' with {DocumentCount} document id(s).", handlers.Length, SanitizeLogValue(profile.IndexFullName), documentIds.Length); + _logger.LogTrace("Notifying {HandlerCount} search document handler(s) after add/update for index '{IndexName}' with {DocumentCount} document id(s).", handlers.Length, profile.IndexFullName.SanitizeForLog(), documentIds.Length); } foreach (var handler in handlers) @@ -208,7 +204,7 @@ private async Task NotifyDocumentsAddedOrUpdatedAsync(IIndexProfileInfo profile, } catch (Exception ex) { - _logger.LogError(ex, "Search document handler '{HandlerType}' failed after indexing documents into '{IndexName}'.", handler.GetType().Name, SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Search document handler '{HandlerType}' failed after indexing documents into '{IndexName}'.", handler.GetType().Name, profile.IndexFullName.SanitizeForLog()); } } } @@ -224,7 +220,7 @@ private async Task NotifyDocumentsDeletedAsync(IIndexProfileInfo profile, List ExistsAsync(IIndexProfileInfo profile, CancellationToken { if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug(ex, "Azure AI Search index '{IndexName}' was not found.", SanitizeLogValue(indexFullName)); + _logger.LogDebug(ex, "Azure AI Search index '{IndexName}' was not found.", indexFullName.SanitizeForLog()); } return false; } catch (Exception ex) { - _logger.LogError(ex, "Error checking existence of Azure AI Search index '{IndexName}'.", SanitizeLogValue(indexFullName)); + _logger.LogError(ex, "Error checking existence of Azure AI Search index '{IndexName}'.", indexFullName.SanitizeForLog()); throw; } } @@ -133,7 +129,7 @@ public async Task CreateAsync(IIndexProfileInfo profile, IReadOnlyCollection 0) - { - firstLine = firstLine[..newlineIndex]; - } - - if (firstLine.Length > 200) - { - firstLine = firstLine[..200]; - } - - return firstLine.ToString().Trim(); - } - /// /// Escapes a value for safe use in an OData filter expression by replacing /// single quotes with doubled single quotes. diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/IAzureAISearchClientFactory.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/IAzureAISearchClientFactory.cs new file mode 100644 index 00000000..479b6fe6 --- /dev/null +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/IAzureAISearchClientFactory.cs @@ -0,0 +1,23 @@ +using Azure.Search.Documents; +using Azure.Search.Documents.Indexes; + +namespace CrestApps.Core.Azure.AISearch.Services; + +/// +/// Creates Azure AI Search clients from the configured connection options. +/// +public interface IAzureAISearchClientFactory +{ + /// + /// Creates the configured Azure AI Search index client. + /// + /// The configured Azure AI Search index client. + SearchIndexClient CreateSearchIndexClient(); + + /// + /// Creates the configured Azure AI Search client for a specific index. + /// + /// The remote index name. + /// The Azure AI Search client for the specified index. + SearchClient CreateSearchClient(string indexFullName); +} diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs index 61cb499a..0a01b977 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs @@ -3,7 +3,6 @@ using CrestApps.Core.Elasticsearch.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.DataSources; -using Elastic.Clients.Elasticsearch; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -20,25 +19,6 @@ public static IServiceCollection AddCoreElasticsearchServices(this IServiceColle ArgumentNullException.ThrowIfNull(configuration); services.Configure(configuration); - var options = new ElasticsearchConnectionOptions(); - - configuration.Bind(options); - - if (!string.IsNullOrEmpty(options.Url)) - { - var settings = new ElasticsearchClientSettings(new Uri(options.Url)); - if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password)) - { - settings.Authentication(new Elastic.Transport.BasicAuthentication(options.Username, options.Password)); - } - - if (!string.IsNullOrEmpty(options.CertificateFingerprint)) - { - settings.CertificateFingerprint(options.CertificateFingerprint); - } - - services.TryAddSingleton(new ElasticsearchClient(settings)); - } return services.AddCoreElasticsearchServices(); } @@ -47,20 +27,24 @@ public static IServiceCollection AddCoreElasticsearchServices(this IServiceColle { ArgumentNullException.ThrowIfNull(services); + services.AddOptions(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService().Create()); + services.TryAddKeyedScoped(ElasticsearchConstants.ProviderName, (sp, _) - => new ElasticsearchDataSourceContentManager(sp.GetRequiredService(), sp.GetRequiredService>())); + => new ElasticsearchDataSourceContentManager(sp.GetRequiredService().Create(), sp.GetRequiredService>())); services.TryAddKeyedScoped(ElasticsearchConstants.ProviderName, (sp, _) - => new DataSourceElasticsearchDocumentReader(sp.GetRequiredService())); + => new DataSourceElasticsearchDocumentReader(sp.GetRequiredService().Create())); services.TryAddKeyedSingleton(ElasticsearchConstants.ProviderName, (_, _) => new ElasticsearchODataFilterTranslator()); services.TryAddKeyedScoped(ElasticsearchConstants.ProviderName, (sp, _) - => new ElasticsearchSearchIndexManager(sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService>())); + => new ElasticsearchSearchIndexManager(sp.GetRequiredService().Create(), sp.GetRequiredService>(), sp.GetRequiredService>())); services.TryAddKeyedScoped(ElasticsearchConstants.ProviderName, (sp, _) - => new ElasticsearchSearchDocumentManager(sp.GetRequiredService(), sp.GetServices(), sp.GetRequiredService>())); + => new ElasticsearchSearchDocumentManager(sp.GetRequiredService().Create(), sp.GetServices(), sp.GetRequiredService>())); return services; } diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/Services/DataSourceElasticsearchDocumentReader.cs b/src/Primitives/CrestApps.Core.Elasticsearch/Services/DataSourceElasticsearchDocumentReader.cs index bcb28fe1..eb41e7d7 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/Services/DataSourceElasticsearchDocumentReader.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/Services/DataSourceElasticsearchDocumentReader.cs @@ -176,7 +176,7 @@ private static SourceDocument ExtractDocument(JsonObject source, string titleFie if (string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(content)) { - title = ExtractTitleFromContent(content); + title = content.ExtractTitleFromContent(); } // Populate all source fields for filter field propagation. @@ -235,22 +235,4 @@ private static JsonNode ResolveFieldValue(JsonObject source, string fieldPath) return current; } - - private static string ExtractTitleFromContent(string content) - { - var firstLine = content.AsSpan(); - var newlineIndex = firstLine.IndexOfAny('\r', '\n'); - - if (newlineIndex > 0) - { - firstLine = firstLine[..newlineIndex]; - } - - if (firstLine.Length > 200) - { - firstLine = firstLine[..200]; - } - - return firstLine.ToString().Trim(); - } } diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs new file mode 100644 index 00000000..70c7aaac --- /dev/null +++ b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs @@ -0,0 +1,85 @@ +using Elastic.Clients.Elasticsearch; +using Elastic.Transport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Elasticsearch.Services; + +/// +/// Creates Elasticsearch clients from the current connection options. +/// +public sealed class ElasticsearchClientFactory : IElasticsearchClientFactory +{ + private readonly ILogger _logger; + private readonly ElasticsearchConnectionOptions _options; + private readonly object _syncLock = new(); + + private ElasticsearchClient _client; + + public ElasticsearchClientFactory( + ILogger logger, + IOptions options) + { + _logger = logger; + _options = options.Value; + } + + public ElasticsearchClient Create() + { + if (_client != null) + { + return _client; + } + + lock (_syncLock) + { + _client ??= Create(_options); + } + + return _client; + } + + public ElasticsearchClient Create(ElasticsearchConnectionOptions configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + + if (string.IsNullOrWhiteSpace(configuration.Url)) + { + throw new InvalidOperationException("Elasticsearch is not configured."); + } + + if (!Uri.TryCreate(configuration.Url.Trim(), UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException("The Elasticsearch URL is invalid."); + } + + var settings = new ElasticsearchClientSettings(endpoint); + var hasUsername = !string.IsNullOrWhiteSpace(configuration.Username); + var hasPassword = !string.IsNullOrWhiteSpace(configuration.Password); + + if (hasUsername != hasPassword) + { + throw new InvalidOperationException("Elasticsearch basic authentication requires both username and password."); + } + + if (hasUsername) + { + settings.Authentication(new BasicAuthentication(configuration.Username, configuration.Password)); + } + + if (!string.IsNullOrWhiteSpace(configuration.CertificateFingerprint)) + { + settings.CertificateFingerprint(configuration.CertificateFingerprint.Trim()); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Creating Elasticsearch client for endpoint '{Endpoint}' with authentication configured: {HasAuthentication}.", + endpoint, + hasUsername); + } + + return new ElasticsearchClient(settings); + } +} diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchSearchDocumentManager.cs b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchSearchDocumentManager.cs index ecad0420..4381bd43 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchSearchDocumentManager.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchSearchDocumentManager.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Support; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.Core.Bulk; using Microsoft.Extensions.Logging; @@ -27,11 +28,6 @@ public ElasticsearchSearchDocumentManager( _logger = logger; } - private static string SanitizeLogValue(string value) - { - return value?.Replace("\r", string.Empty).Replace("\n", string.Empty) ?? string.Empty; - } - public async Task AddOrUpdateAsync(IIndexProfileInfo profile, IReadOnlyCollection documents, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(profile); @@ -62,7 +58,7 @@ public async Task AddOrUpdateAsync(IIndexProfileInfo profile, IReadOnlyCol var response = await _elasticClient.BulkAsync(request, cancellationToken); if (!response.IsValidResponse) { - _logger.LogWarning("Elasticsearch bulk index failed for index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogWarning("Elasticsearch bulk index failed for index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); return false; } @@ -72,7 +68,7 @@ public async Task AddOrUpdateAsync(IIndexProfileInfo profile, IReadOnlyCol } catch (Exception ex) { - _logger.LogError(ex, "Error indexing documents in Elasticsearch index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error indexing documents in Elasticsearch index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); return false; } } @@ -102,14 +98,14 @@ public async Task DeleteAsync(IIndexProfileInfo profile, IEnumerable doc var response = await _elasticClient.BulkAsync(request, cancellationToken); if (!response.IsValidResponse) { - _logger.LogWarning("Elasticsearch bulk delete failed for index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogWarning("Elasticsearch bulk delete failed for index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } await NotifyDocumentsDeletedAsync(profile, ids, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error deleting documents from Elasticsearch index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error deleting documents from Elasticsearch index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } } @@ -123,12 +119,12 @@ public async Task DeleteAllAsync(IIndexProfileInfo profile, CancellationToken ca })), cancellationToken); if (!response.IsValidResponse) { - _logger.LogWarning("Elasticsearch delete all failed for index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogWarning("Elasticsearch delete all failed for index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } } catch (Exception ex) { - _logger.LogError(ex, "Error deleting all documents from Elasticsearch index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error deleting all documents from Elasticsearch index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); } } @@ -153,7 +149,7 @@ private async Task NotifyDocumentsAddedOrUpdatedAsync(IIndexProfileInfo profile, if (_logger.IsEnabled(LogLevel.Trace)) { - _logger.LogTrace("Notifying {HandlerCount} search document handler(s) after add/update for index '{IndexName}' with {DocumentCount} document id(s).", handlers.Length, SanitizeLogValue(profile.IndexFullName), documentIds.Length); + _logger.LogTrace("Notifying {HandlerCount} search document handler(s) after add/update for index '{IndexName}' with {DocumentCount} document id(s).", handlers.Length, profile.IndexFullName.SanitizeForLog(), documentIds.Length); } foreach (var handler in handlers) @@ -164,7 +160,7 @@ private async Task NotifyDocumentsAddedOrUpdatedAsync(IIndexProfileInfo profile, } catch (Exception ex) { - _logger.LogError(ex, "Search document handler '{HandlerType}' failed after indexing documents into '{IndexName}'.", handler.GetType().Name, SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Search document handler '{HandlerType}' failed after indexing documents into '{IndexName}'.", handler.GetType().Name, profile.IndexFullName.SanitizeForLog()); } } } @@ -180,7 +176,7 @@ private async Task NotifyDocumentsDeletedAsync(IIndexProfileInfo profile, List ExistsAsync(IIndexProfileInfo profile, CancellationToken } catch (Exception ex) { - _logger.LogError(ex, "Error checking existence of Elasticsearch index '{IndexName}'.", SanitizeLogValue(indexFullName)); + _logger.LogError(ex, "Error checking existence of Elasticsearch index '{IndexName}'.", indexFullName.SanitizeForLog()); throw; } } @@ -89,13 +85,13 @@ public async Task CreateAsync(IIndexProfileInfo profile, IReadOnlyCollection c.Mappings(m => m.Properties(properties)), cancellationToken); if (!response.IsValidResponse) { - _logger.LogWarning("Failed to create Elasticsearch index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogWarning("Failed to create Elasticsearch index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); throw new InvalidOperationException($"Failed to create Elasticsearch index '{profile.IndexFullName}'."); } } catch (Exception ex) { - _logger.LogError(ex, "Error creating Elasticsearch index '{IndexName}'.", SanitizeLogValue(profile.IndexFullName)); + _logger.LogError(ex, "Error creating Elasticsearch index '{IndexName}'.", profile.IndexFullName.SanitizeForLog()); throw; } } @@ -110,12 +106,12 @@ public async Task DeleteAsync(IIndexProfileInfo profile, CancellationToken cance var response = await _elasticClient.Indices.DeleteAsync(indexFullName, cancellationToken); if (!response.IsValidResponse) { - _logger.LogWarning("Failed to delete Elasticsearch index '{IndexName}'.", SanitizeLogValue(indexFullName)); + _logger.LogWarning("Failed to delete Elasticsearch index '{IndexName}'.", indexFullName.SanitizeForLog()); } } catch (Exception ex) { - _logger.LogError(ex, "Error deleting Elasticsearch index '{IndexName}'.", SanitizeLogValue(indexFullName)); + _logger.LogError(ex, "Error deleting Elasticsearch index '{IndexName}'.", indexFullName.SanitizeForLog()); throw; } } diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/Services/IElasticsearchClientFactory.cs b/src/Primitives/CrestApps.Core.Elasticsearch/Services/IElasticsearchClientFactory.cs new file mode 100644 index 00000000..7f807122 --- /dev/null +++ b/src/Primitives/CrestApps.Core.Elasticsearch/Services/IElasticsearchClientFactory.cs @@ -0,0 +1,22 @@ +using Elastic.Clients.Elasticsearch; + +namespace CrestApps.Core.Elasticsearch.Services; + +/// +/// Creates Elasticsearch clients from the configured connection options. +/// +public interface IElasticsearchClientFactory +{ + /// + /// Creates the configured Elasticsearch client. + /// + /// The configured Elasticsearch client. + ElasticsearchClient Create(); + + /// + /// Creates an Elasticsearch client for the supplied configuration. + /// + /// The Elasticsearch connection settings to use. + /// The configured Elasticsearch client. + ElasticsearchClient Create(ElasticsearchConnectionOptions configuration); +} diff --git a/src/Primitives/CrestApps.Core.Infrastructure/DataSourceConstants.cs b/src/Primitives/CrestApps.Core.Infrastructure/DataSourceConstants.cs index 7f4a580a..55e1bc57 100644 --- a/src/Primitives/CrestApps.Core.Infrastructure/DataSourceConstants.cs +++ b/src/Primitives/CrestApps.Core.Infrastructure/DataSourceConstants.cs @@ -2,7 +2,7 @@ namespace CrestApps.Core.Infrastructure; public static class DataSourceConstants { - public static readonly string IndexingTaskType = "DataSourceIndex"; + public const string IndexingTaskType = "DataSourceIndex"; public static class ColumnNames { diff --git a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs index 5988dcdf..e8904955 100644 --- a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs +++ b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs @@ -63,7 +63,7 @@ public static string GetStringValue(this IDictionary entry, stri return null; } - throw new InvalidOperationException($"The '{key}' does not exists in the dictionary."); + throw new InvalidOperationException($"The '{key}' does not exist in the dictionary."); } public static bool GetBooleanOrFalseValue(this IDictionary entry, string key, bool throwException = false) @@ -91,6 +91,6 @@ public static bool GetBooleanOrFalseValue(this IDictionary entry return false; } - throw new InvalidOperationException($"The '{key}' does not exists in the dictionary."); + throw new InvalidOperationException($"The '{key}' does not exist in the dictionary."); } } diff --git a/src/Primitives/CrestApps.Core.Infrastructure/DocumentIndexConstants.cs b/src/Primitives/CrestApps.Core.Infrastructure/DocumentIndexConstants.cs index 64a0b590..470d7df7 100644 --- a/src/Primitives/CrestApps.Core.Infrastructure/DocumentIndexConstants.cs +++ b/src/Primitives/CrestApps.Core.Infrastructure/DocumentIndexConstants.cs @@ -26,21 +26,4 @@ public static class ColumnNames public const string ChunkIndex = "chunkIndex"; } - - public static class MemoryColumnNames - { - public const string MemoryId = "memoryId"; - - public const string UserId = "userId"; - - public const string Name = "name"; - - public const string Description = "description"; - - public const string Content = "content"; - - public const string Embedding = "embedding"; - - public const string UpdatedUtc = "updatedUtc"; - } } diff --git a/src/Primitives/CrestApps.Core.Infrastructure/HandlerExtensions.cs b/src/Primitives/CrestApps.Core.Infrastructure/HandlerExtensions.cs deleted file mode 100644 index 0be63ce9..00000000 --- a/src/Primitives/CrestApps.Core.Infrastructure/HandlerExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace CrestApps.Core.Infrastructure; - -public static class HandlerExtensions -{ - public static async Task InvokeHandlersAsync( - this IEnumerable handlers, - Func action, - TContext context, - ILogger logger) - { - foreach (var handler in handlers) - { - try - { - await action(handler, context); - } - catch (Exception ex) - { - logger.LogError(ex, "Error invoking handler '{HandlerType}'.", handler.GetType().Name); - } - } - } -} diff --git a/src/Primitives/CrestApps.Core.Templates/Services/DefaultTemplateService.cs b/src/Primitives/CrestApps.Core.Templates/Services/DefaultTemplateService.cs index 15591767..f05e8a7b 100644 --- a/src/Primitives/CrestApps.Core.Templates/Services/DefaultTemplateService.cs +++ b/src/Primitives/CrestApps.Core.Templates/Services/DefaultTemplateService.cs @@ -14,6 +14,8 @@ public class DefaultTemplateService : ITemplateService private readonly IEnumerable _providers; private readonly ITemplateEngine _renderer; + private IReadOnlyList