diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Connections/IAIProviderConnectionStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Connections/IAIProviderConnectionStore.cs new file mode 100644 index 00000000..8396fb0c --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Connections/IAIProviderConnectionStore.cs @@ -0,0 +1,12 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Connections; + +/// +/// Provides persisted storage for AI provider connections while preserving the standard +/// named-and-sourced catalog operations used by connection managers and editors. +/// +public interface IAIProviderConnectionStore : INamedSourceCatalog +{ +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs new file mode 100644 index 00000000..94c4bcd9 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedCatalogSource.cs @@ -0,0 +1,27 @@ +namespace CrestApps.Core.Services; + +/// +/// Represents a read-only binding source of catalog entries for models that are identified +/// by name. Each source is ordered by (lower values have higher priority). +/// +/// The type of catalog entry. +public interface INamedCatalogSource + where T : INameAwareModel +{ + /// + /// Gets the priority order of this source. Lower values indicate higher priority. + /// When entries with the same name exist in multiple sources, the source with the + /// lower order value wins. + /// + int Order { get; } + + /// + /// Asynchronously retrieves all entries provided by this source. + /// + /// + /// Entries already collected from higher-priority sources, allowing this source + /// to skip entries whose names conflict with existing ones. + /// + /// A read-only collection of entries from this source. + ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries); +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogSource.cs new file mode 100644 index 00000000..b1f83781 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/INamedSourceCatalogSource.cs @@ -0,0 +1,13 @@ +namespace CrestApps.Core.Services; + +/// +/// Represents a read-only binding source of catalog entries for models that are identified +/// by both name and source. Extends with the additional +/// constraint. Each source is ordered by +/// (lower values have higher priority). +/// +/// The type of catalog entry. +public interface INamedSourceCatalogSource : INamedCatalogSource + where T : INameAwareModel, ISourceAwareModel +{ +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs new file mode 100644 index 00000000..93029f64 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedCatalogSource.cs @@ -0,0 +1,30 @@ +namespace CrestApps.Core.Services; + +/// +/// Extends with write operations +/// (create, update, delete), allowing the multi-source catalog to delegate +/// mutations to a persistent source. +/// +/// The type of catalog entry. +public interface IWritableNamedCatalogSource : INamedCatalogSource + where T : INameAwareModel +{ + /// + /// Asynchronously deletes the specified entry from this source. + /// + /// The entry to delete. + /// if the entry was successfully deleted; otherwise, . + ValueTask DeleteAsync(T entry); + + /// + /// Asynchronously creates the specified entry in this source. + /// + /// The entry to create. + ValueTask CreateAsync(T entry); + + /// + /// Asynchronously updates the specified entry in this source. + /// + /// The entry to update. + ValueTask UpdateAsync(T entry); +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedSourceCatalogSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedSourceCatalogSource.cs new file mode 100644 index 00000000..f561018e --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/IWritableNamedSourceCatalogSource.cs @@ -0,0 +1,12 @@ +namespace CrestApps.Core.Services; + +/// +/// Extends and +/// with write operations for models that have both name and source. Allows the multi-source +/// catalog to delegate mutations to a persistent source. +/// +/// The type of catalog entry. +public interface IWritableNamedSourceCatalogSource : INamedSourceCatalogSource, IWritableNamedCatalogSource + where T : INameAwareModel, ISourceAwareModel +{ +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs new file mode 100644 index 00000000..0acd019c --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableCatalogBindingSource.cs @@ -0,0 +1,35 @@ +namespace CrestApps.Core.Services; + +/// +/// Wraps an existing as a writable multi-source +/// binding source. Used by persistence layers (YesSql, EntityCore) to expose their +/// DB-backed catalogs as sources for the multi-source store. +/// +/// The type of catalog entry. +public class WritableCatalogBindingSource : IWritableNamedSourceCatalogSource + where T : INameAwareModel, ISourceAwareModel +{ + private readonly INamedSourceCatalog _inner; + + public WritableCatalogBindingSource(INamedSourceCatalog inner) + { + _inner = inner; + } + + /// + /// Gets the priority order. DB-backed sources use 0 (highest priority). + /// + public int Order => 0; + + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) + => _inner.GetAllAsync(); + + public ValueTask DeleteAsync(T entry) + => _inner.DeleteAsync(entry); + + public ValueTask CreateAsync(T entry) + => _inner.CreateAsync(entry); + + public ValueTask UpdateAsync(T entry) + => _inner.UpdateAsync(entry); +} diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs new file mode 100644 index 00000000..55ce1867 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/Services/WritableNamedCatalogBindingSource.cs @@ -0,0 +1,35 @@ +namespace CrestApps.Core.Services; + +/// +/// Wraps an existing as a writable multi-source +/// binding source. Used by persistence layers to expose their DB-backed catalogs +/// as sources for the multi-source store when the model has no source property. +/// +/// The type of catalog entry. +public class WritableNamedCatalogBindingSource : IWritableNamedCatalogSource + where T : INameAwareModel +{ + private readonly INamedCatalog _inner; + + public WritableNamedCatalogBindingSource(INamedCatalog inner) + { + _inner = inner; + } + + /// + /// Gets the priority order. DB-backed sources use 0 (highest priority). + /// + public int Order => 0; + + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) + => _inner.GetAllAsync(); + + public ValueTask DeleteAsync(T entry) + => _inner.DeleteAsync(entry); + + public ValueTask CreateAsync(T entry) + => _inner.CreateAsync(entry); + + public ValueTask UpdateAsync(T entry) + => _inner.UpdateAsync(entry); +} diff --git a/src/CrestApps.Core.Docs/docs/changelog/index.md b/src/CrestApps.Core.Docs/docs/changelog/index.md index 1372fcf4..fbbc8c23 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/index.md +++ b/src/CrestApps.Core.Docs/docs/changelog/index.md @@ -11,4 +11,5 @@ This section tracks `CrestApps.Core` releases and notable repository-level chang | Version | Highlights | | --- | --- | +| [1.1.0](v1.1.0) | Multi-source binding pattern for AI stores, generic YesSql/EntityCore binding source extensions, connection loading fix | | [1.0.0](v1.0.0) | Initial standalone release plus merged configuration catalogs, clearer quick-start guidance, and deployment configuration diagnostics | diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.1.0.md new file mode 100644 index 00000000..8f44b5fe --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.1.0.md @@ -0,0 +1,56 @@ +--- +sidebar_label: 1.1.0 Release Notes +sidebar_position: 3 +title: "Version 1.1.0 Release Notes" +description: Multi-source binding pattern, generic store extensions, and connection loading fix. +--- + +# Version 1.1.0 Release Notes + +**Package version**: `1.1.0` + +## Highlights + +- introduces the **multi-source binding pattern** for AI deployments and connections, replacing the previous decorator-based store pattern with an aggregation model that merges entries from multiple binding sources +- ships `INamedCatalogSource` and `INamedSourceCatalogSource` interfaces with writable counterparts, ordered-priority merging base classes (`MultiSourceNamedCatalog` and `MultiSourceNamedSourceCatalog`), and `WritableCatalogBindingSource` / `WritableNamedCatalogBindingSource` adapters +- registers `DefaultAIDeploymentStore` and `DefaultAIProviderConnectionStore` as the built-in multi-source stores in `AddCoreAIServices()`, with configuration-backed binding sources at Order 100 so that `appsettings.json` entries are always available without a persistence package +- adds generic YesSql extensions (`AddYesSqlNamedSourceBindingSource()`, `AddYesSqlNamedBindingSource()`) and EntityCore extensions (`AddEntityCoreNamedSourceBindingSource()`, `AddEntityCoreNamedBindingSource()`) for registering DB-backed binding sources without manual adapter wiring +- fixes a bug where AI provider connections from `appsettings.json` were not visible in the MVC sample because `AddYesSqlNamedSourceDocumentCatalog()` replaced the multi-source store forwarding +- evaluates all configured connection and provider sections from `AIProviderConnectionCatalogOptions` so that connections from both `ConnectionSections` and `ProviderSections` are discoverable +- reduces unnecessary allocations by removing `.ToArray()` calls on `Dictionary.Values` in configuration sources and materializing filtered collections once in `PageAsync` + +## Breaking Changes + +- `ConfigurationAIDeploymentStore` and `ConfigurationAIProviderConnectionCatalog` have been removed; their responsibilities are now split between the multi-source stores (`DefaultAIDeploymentStore`, `DefaultAIProviderConnectionStore`) and the configuration binding sources (`ConfigurationAIDeploymentSource`, `ConfigurationAIProviderConnectionSource`) +- hosts that previously registered `ConfigurationAIDeploymentStore` or `ConfigurationAIProviderConnectionCatalog` directly should remove those registrations — `AddCoreAIServices()` handles everything automatically +- the `WritableCatalogSourceAdapter` class has been renamed to `WritableCatalogBindingSource` + +## Migration Guide + +### Minimal change + +If your host calls `AddCoreAIServices()` (or `AddAISuite()`), no changes are needed for the deployment and connection stores — they are registered automatically with appsettings-backed binding sources. + +### Adding database-backed storage + +To persist deployments and connections in a database alongside the appsettings entries, register the appropriate binding source: + +**YesSql:** + +```csharp +services.AddYesSqlNamedSourceBindingSource(); +services.AddYesSqlNamedSourceBindingSource(); +``` + +**Entity Framework Core:** + +```csharp +services.AddEntityCoreNamedSourceBindingSource(); +services.AddEntityCoreNamedSourceBindingSource(); +``` + +Or call `AddEntityCoreStores()` which registers both automatically. + +### Custom binding sources + +To supply entries from an additional source (remote API, embedded resources, etc.), implement `INamedSourceCatalogSource` and register with `TryAddEnumerable`. See the [Data Storage — Multi-Source Binding Pattern](../core/data-storage.md#multi-source-binding-pattern) documentation for full examples. diff --git a/src/CrestApps.Core.Docs/docs/core/ai-core.md b/src/CrestApps.Core.Docs/docs/core/ai-core.md index a7a2b371..34c239ab 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-core.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-core.md @@ -52,9 +52,13 @@ A **provider connection** stores credentials and endpoint information for a spec | `IAIClientFactory` | `DefaultAIClientFactory` | Scoped | Creates typed AI clients | | `IAICompletionService` | `DefaultAICompletionService` | Scoped | Deployment-aware completion | | `IAICompletionContextBuilder` | `DefaultAICompletionContextBuilder` | Scoped | Builds context with handler pipeline | +| `IAIDeploymentStore` | `DefaultAIDeploymentStore` | Scoped | Multi-source deployment store (merges DB + config entries) | +| `IAIProviderConnectionStore` | `DefaultAIProviderConnectionStore` | Scoped | Multi-source connection store (merges DB + config entries) | +| `INamedSourceCatalogSource` | `ConfigurationAIDeploymentSource` | Scoped | Reads deployments from `appsettings.json` (Order 100) | +| `INamedSourceCatalogSource` | `ConfigurationAIProviderConnectionSource` | Scoped | Reads connections from `appsettings.json` (Order 100) | | `ITemplateService` | *(from AddCoreAITemplating)* | Scoped | Template rendering | -It also chains `AddCoreAITemplating()` and `AddCoreServices()` automatically. +It also chains `AddCoreAITemplating()` and `AddCoreServices()` automatically, and forwards `IAIDeploymentStore` to `INamedSourceCatalog`, `INamedCatalog`, `ISourceCatalog`, and `ICatalog` (and the same forwarding for `AIProviderConnection`). This means you can inject any of these catalog interfaces and they will resolve through the multi-source store. Optional format-specific packages stay opt-in. For example, Markdown-aware normalization lives in `CrestApps.Core.AI.Markdown`, so hosts that want Markdig-backed RAG normalization should register `AddCoreAIMarkdown()` explicitly instead of expecting `AddCoreAIServices()` to pull it in automatically. diff --git a/src/CrestApps.Core.Docs/docs/core/data-storage.md b/src/CrestApps.Core.Docs/docs/core/data-storage.md index 7a3b91db..9aed0faa 100644 --- a/src/CrestApps.Core.Docs/docs/core/data-storage.md +++ b/src/CrestApps.Core.Docs/docs/core/data-storage.md @@ -103,6 +103,8 @@ public interface INamedSourceCatalog : INamedCatalog, ISourceCatalog ## DI Extension Methods +### YesSql catalog extensions + | Method | Registers | Requires | |--------|-----------|----------| | `AddYesSqlDocumentCatalog()` | `ICatalog` | `CatalogItem` + `CatalogItemIndex` | @@ -110,6 +112,17 @@ public interface INamedSourceCatalog : INamedCatalog, ISourceCatalog | `AddYesSqlSourceDocumentCatalog()` | `ICatalog` + `ISourceCatalog` | + `ISourceAwareModel` + `ISourceAwareIndex` | | `AddYesSqlNamedSourceDocumentCatalog()` | All four interfaces | Both `INameAware*` + `ISourceAware*` | +### YesSql binding source extensions + +These register a YesSql-backed catalog as a **binding source** for the multi-source store pattern (see [Multi-Source Binding Pattern](#multi-source-binding-pattern) below): + +| Method | Binding source registered | Requires | +|--------|--------------------------|----------| +| `AddYesSqlNamedSourceBindingSource()` | `INamedSourceCatalogSource` | `CatalogItem` + both `INameAware*` + `ISourceAware*` | +| `AddYesSqlNamedBindingSource()` | `INamedCatalogSource` | `CatalogItem` + `INameAwareModel` | + +### Entity Framework Core catalog extensions + The Entity Framework Core package exposes the same service-registration shape without YesSql indexes: | Method | Registers | Requires | @@ -119,7 +132,18 @@ The Entity Framework Core package exposes the same service-registration shape wi | `AddSourceDocumentCatalog()` | `ICatalog` + `ISourceCatalog` | `CatalogItem` + `ISourceAwareModel` | | `AddNamedSourceDocumentCatalog()` | All four interfaces | `CatalogItem` + both awareness interfaces | -`AddEntityCoreStores()` registers the built-in CrestApps store interfaces (`IAIChatSessionManager`, prompt stores, document stores, memory stores, search index profile store, and related catalog registrations) against the Entity Framework Core package. +### Entity Framework Core binding source extensions + +These register an EntityCore-backed catalog as a **binding source** for the multi-source store pattern: + +| Method | Binding source registered | Requires | +|--------|--------------------------|----------| +| `AddEntityCoreNamedSourceBindingSource()` | `INamedSourceCatalogSource` | `SourceCatalogEntry` + `INameAwareModel` | +| `AddEntityCoreNamedBindingSource()` | `INamedCatalogSource` | `CatalogItem` + `INameAwareModel` | + +### Bulk store registration + +`AddEntityCoreStores()` registers the built-in CrestApps store interfaces (`IAIChatSessionManager`, prompt stores, document stores, memory stores, search index profile store, and related catalog registrations) against the Entity Framework Core package. It also registers the multi-source binding sources for `AIProviderConnection` and `AIDeployment`. ## Catalog Entry Handlers @@ -194,6 +218,89 @@ In `CrestApps.Core.Data.YesSql`, the shared convention is to keep each index typ | `McpResource` | Source | MCP resources | | `A2AConnection` | Basic | A2A connections | +## Feature Store Requirements + +Each feature requires specific stores to be registered. The table below lists what each feature needs and the corresponding registration calls for YesSql and Entity Framework Core. + +:::tip +`AddCoreAIServices()` registers the multi-source stores for `AIDeployment` and `AIProviderConnection` with appsettings-backed binding sources automatically. You only need to register the persistence-layer binding sources to enable database-backed storage. +::: + +### Core AI (always required) + +Registered automatically by `AddCoreAIServices()`: + +| Model | Store interface | Registration | +|-------|----------------|--------------| +| `AIDeployment` | `IAIDeploymentStore` | Auto-registered (multi-source, config source at Order 100) | +| `AIProviderConnection` | `IAIProviderConnectionStore` | Auto-registered (multi-source, config source at Order 100) | + +Add a DB binding source if you want database-backed deployments and connections: + +```csharp +// YesSql +services.AddYesSqlNamedSourceBindingSource(); +services.AddYesSqlNamedSourceBindingSource(); + +// Entity Framework Core (included in AddEntityCoreStores()) +services.AddEntityCoreNamedSourceBindingSource(); +services.AddEntityCoreNamedSourceBindingSource(); +``` + +### AI Profiles + +| Model | Catalog registration | YesSql | EntityCore | +|-------|---------------------|--------|------------| +| `AIProfile` | `INamedSourceCatalog` | `AddYesSqlNamedSourceDocumentCatalog()` | `AddNamedSourceDocumentCatalog>()` | +| `AIProfileTemplate` | `INamedSourceCatalog` | `AddYesSqlNamedSourceDocumentCatalog()` | `AddNamedSourceDocumentCatalog>()` | + +### Chat + +| Model | Store interface | Registration | +|-------|----------------|--------------| +| `AIChatSession` | `IAIChatSessionManager` | `AddScoped()` (YesSql) or `EntityCoreAIChatSessionManager` (EF Core) | +| `AIChatSessionPrompt` | `IAIChatSessionPromptStore` | `AddScoped()` (YesSql) or `EntityCoreAIChatSessionPromptStore` (EF Core) | + +### Chat Interactions + +| Model | Catalog registration | YesSql | EntityCore | +|-------|---------------------|--------|------------| +| `ChatInteraction` | `ICatalog` | `AddYesSqlDocumentCatalog()` | `AddDocumentCatalog>()` | +| `ChatInteractionPrompt` | `IChatInteractionPromptStore` | `AddScoped()` | `EntityCoreChatInteractionPromptStore` | + +### Documents and Data Sources + +| Model | Store interface | Registration | +|-------|----------------|--------------| +| `AIDocument` | `IAIDocumentStore` | `AddScoped()` or `EntityCoreAIDocumentStore` | +| `AIDocumentChunk` | `IAIDocumentChunkStore` | `AddScoped()` or `EntityCoreAIDocumentChunkStore` | +| `AIDataSource` | `IAIDataSourceStore` | `AddScoped()` or `EntityCoreAIDataSourceStore` | +| `SearchIndexProfile` | `ISearchIndexProfileStore` | `AddScoped()` or `EntityCoreSearchIndexProfileStore` | + +### Memory + +| Model | Store interface | Registration | +|-------|----------------|--------------| +| `AIMemoryEntry` | `IAIMemoryStore` | `AddScoped()` or `EntityCoreAIMemoryStore` | + +### MCP (Model Context Protocol) + +| Model | Catalog registration | YesSql | EntityCore | +|-------|---------------------|--------|------------| +| `McpConnection` | `ISourceCatalog` | `AddYesSqlSourceDocumentCatalog()` | `AddSourceDocumentCatalog>()` | +| `McpPrompt` | `INamedCatalog` | `AddYesSqlNamedDocumentCatalog()` | `AddNamedDocumentCatalog>()` | +| `McpResource` | `ISourceCatalog` | `AddYesSqlSourceDocumentCatalog()` | `AddSourceDocumentCatalog>()` | + +### A2A (Agent-to-Agent) + +| Model | Catalog registration | YesSql | EntityCore | +|-------|---------------------|--------|------------| +| `A2AConnection` | `ICatalog` | `AddYesSqlDocumentCatalog()` | `AddDocumentCatalog>()` | + +:::note +`AddEntityCoreStores()` registers all of the above EntityCore stores in a single call. YesSql hosts must register each catalog individually because they also need to register index providers and create index tables during startup. +::: + ## First-party Entity Framework Core package The `CrestApps.Core.Data.EntityCore` package gives you a ready-made alternative when you want the CrestApps store surface without YesSql. @@ -275,48 +382,207 @@ services.AddScoped(); The filter infrastructure calls your committer automatically — no other wiring is required. -## Composite Catalogs +## Multi-Source Binding Pattern + +When a model needs entries that come from more than one place — for example, AI deployments defined in `appsettings.json` merged with deployments stored in a database — the framework uses **binding sources**. Each source supplies entries independently, and a multi-source store aggregates them at runtime, deduplicating by name (the lowest-order source wins). + +### How it works + +```text +┌────────────────────────────────────────────────────────┐ +│ DefaultAIDeploymentStore (MultiSourceNamedSourceCatalog) │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────────┐ │ +│ │ DB binding source │ │ Config binding source │ │ +│ │ Order = 0 (wins) │ │ Order = 100 (fallback) │ │ +│ │ YesSql / EntityCore │ │ appsettings.json │ │ +│ └─────────────────────┘ └─────────────────────────┘ │ +└────────────────────────────────────────────────────────┘ +``` + +1. Each **binding source** implements `INamedSourceCatalogSource` (or `INamedCatalogSource` for models without a source property). +2. Sources declare an `Order` property — lower values win when two sources provide entries with the same name. +3. The **multi-source store** iterates all sources in order and builds a deduplicated list. +4. Write operations (create, update, delete) are delegated to the first **writable** source. -When models need to be loaded from multiple sources (e.g., code-defined defaults merged with database entries), use the **CatalogManager** pattern. The `CatalogManager` delegates reads across all registered `ICatalog` instances and merges the results: +### Binding source interfaces ```csharp -public sealed class CatalogManager( - ICatalog primaryCatalog, - IEnumerable> additionalSources) where T : CatalogItem +// Read-only source for named models +public interface INamedCatalogSource where T : INameAwareModel { - public async ValueTask> GetAllAsync() + int Order { get; } + ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries); +} + +// Read-only source for named + source-aware models +public interface INamedSourceCatalogSource : INamedCatalogSource + where T : INameAwareModel, ISourceAwareModel { } + +// Writable source for named models +public interface IWritableNamedCatalogSource : INamedCatalogSource + where T : INameAwareModel +{ + ValueTask DeleteAsync(T entry); + ValueTask CreateAsync(T entry); + ValueTask UpdateAsync(T entry); +} + +// Writable source for named + source-aware models +public interface IWritableNamedSourceCatalogSource + : INamedSourceCatalogSource, IWritableNamedCatalogSource + where T : INameAwareModel, ISourceAwareModel { } +``` + +### Base classes + +The framework provides two abstract base classes that handle merging, deduplication, filtering, pagination, and write delegation: + +| Base class | For models that implement | Implements | +|------------|--------------------------|------------| +| `MultiSourceNamedCatalog` | `INameAwareModel` | `INamedCatalog` | +| `MultiSourceNamedSourceCatalog` | `INameAwareModel` + `ISourceAwareModel` | `INamedSourceCatalog` | + +Both accept `IEnumerable>` (or the source-aware variant) via constructor injection, order the sources by `Order`, and merge entries by name. + +### Built-in stores + +`AddCoreAIServices()` registers two multi-source stores and their default configuration-backed binding sources automatically: + +| Store | Implements | Binding sources | +|-------|-----------|-----------------| +| `DefaultAIDeploymentStore` | `IAIDeploymentStore` → `INamedSourceCatalog` | `ConfigurationAIDeploymentSource` (Order 100) | +| `DefaultAIProviderConnectionStore` | `IAIProviderConnectionStore` → `INamedSourceCatalog` | `ConfigurationAIProviderConnectionSource` (Order 100) | + +The configuration sources read from `appsettings.json` using the sections configured in `AIDeploymentCatalogOptions` and `AIProviderConnectionCatalogOptions`: + +| Options class | Default sections | +|--------------|------------------| +| `AIDeploymentCatalogOptions` | `CrestApps:AI:Deployments` | +| `AIProviderConnectionCatalogOptions` | `CrestApps:AI:Connections` (connection sections) and `CrestApps:AI:Providers` (provider sections) | + +When a persistence package (YesSql or EntityCore) is added, it registers an additional **writable** DB binding source at Order 0, so database entries take priority over `appsettings.json` entries and all write operations go to the database. + +### Registering DB binding sources + +#### YesSql + +```csharp +// Register a YesSql-backed writable binding source for AI deployments +services.AddYesSqlNamedSourceBindingSource(); + +// Register a YesSql-backed writable binding source for AI connections +services.AddYesSqlNamedSourceBindingSource(); +``` + +#### Entity Framework Core + +```csharp +// Register an EntityCore-backed writable binding source for AI deployments +services.AddEntityCoreNamedSourceBindingSource(); + +// Register an EntityCore-backed writable binding source for AI connections +services.AddEntityCoreNamedSourceBindingSource(); +``` + +:::info +`AddEntityCoreStores()` already calls both of the above registrations. You only need to call them explicitly when composing your own store registration. +::: + +### How binding source adapters work + +The framework provides two generic adapter classes that wrap an existing catalog as a writable binding source: + +| Adapter | Wraps | For models with | +|---------|-------|-----------------| +| `WritableCatalogBindingSource` | `INamedSourceCatalog` | Name + Source | +| `WritableNamedCatalogBindingSource` | `INamedCatalog` | Name only | + +Both set `Order = 0` (highest priority) and delegate all read and write operations to the wrapped catalog. The generic YesSql and EntityCore extension methods use these adapters internally. + +### Creating a custom binding source + +To add entries from any source (remote API, file system, embedded resources, etc.), implement `INamedSourceCatalogSource` (or `INamedCatalogSource` for named-only models): + +```csharp +public sealed class RemoteApiDeploymentSource : INamedSourceCatalogSource +{ + private readonly IRemoteDeploymentClient _client; + + public RemoteApiDeploymentSource(IRemoteDeploymentClient client) { - var results = new List(); + _client = client; + } - // Load from the primary (writable) catalog - results.AddRange(await primaryCatalog.GetAllAsync()); + // Order 50 — higher priority than config (100), lower than DB (0) + public int Order => 50; - // Merge from read-only additional sources - foreach (var source in additionalSources) - { - var entries = await source.GetAllAsync(); - foreach (var entry in entries) - { - if (!results.Any(r => r.Id == entry.Id)) - { - results.Add(entry); - } - } - } + public async ValueTask> GetEntriesAsync( + IReadOnlyCollection knownEntries) + { + // knownEntries contains entries from higher-priority sources (DB, etc.) + // Use it to skip entries whose names already exist. + var existingNames = knownEntries + .Select(e => e.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); - return results; + var remoteDeployments = await _client.GetDeploymentsAsync(); + + return remoteDeployments + .Where(d => !existingNames.Contains(d.Name)) + .ToArray(); } } ``` -Register additional read-only sources alongside the primary catalog: +Register it with `TryAddEnumerable` so that it is additive: ```csharp -// Primary writable catalog (YesSql-backed) -builder.Services.AddYesSqlNamedSourceDocumentCatalog(); +services.TryAddEnumerable( + ServiceDescriptor.Scoped, RemoteApiDeploymentSource>()); +``` + +The multi-source store discovers all registered `INamedSourceCatalogSource` instances and merges them automatically. No changes are needed to existing store or controller code. + +### Priority and deduplication rules + +| Priority | Source | Typical registration | +|----------|--------|---------------------| +| 0 (highest) | Database (YesSql / EntityCore) | `AddYesSqlNamedSourceBindingSource` or `AddEntityCoreNamedSourceBindingSource` | +| 1–99 | Custom sources | Registered by application code | +| 100 (lowest) | Configuration (`appsettings.json`) | Registered by `AddCoreAIServices()` | + +When two sources provide an entry with the same `Name`, the entry from the lower-order source wins and the duplicate is skipped. + +### Building a custom multi-source store + +If you need multi-source behavior for your own model type, extend the appropriate base class: + +```csharp +public sealed class DefaultWidgetStore : MultiSourceNamedSourceCatalog, IWidgetStore +{ + public DefaultWidgetStore(IEnumerable> sources) + : base(sources) + { + } + + protected override string GetItemId(Widget entry) => entry.ItemId; +} +``` + +Then register the store and its configuration source: + +```csharp +// Store registration +services.TryAddScoped(); +services.TryAddScoped>(sp => sp.GetRequiredService()); + +// Config-backed source (Order = 100) +services.TryAddEnumerable( + ServiceDescriptor.Scoped, ConfigurationWidgetSource>()); -// Additional read-only source (e.g., code-defined defaults) -builder.Services.AddScoped, DefaultProfilesCatalog>(); +// DB-backed source (Order = 0) — YesSql example +services.AddYesSqlNamedSourceBindingSource(); ``` ## Pagination diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index 249d9097..1d55c940 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -84,6 +84,7 @@ const sidebars = { label: 'Changelog', items: [ 'changelog/index', + 'changelog/v1.1.0', 'changelog/v1.0.0', ], }, 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 7589f490..f7b3853d 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 @@ -11,6 +11,10 @@ Model Context Protocol (MCP) implementation for CrestApps AI services. $(PackageTags) ai mcp model-context-protocol client server + + + + diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 0058b0ad..478a1966 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Handlers; using CrestApps.Core.AI.Memory; @@ -129,6 +130,23 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi .AddScoped() .AddScoped(); + // Register the multi-source stores and forward all catalog interfaces. + services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + + services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + + // Register the configuration-backed sources (Order=100, lower priority than DB). + services.TryAddEnumerable(ServiceDescriptor.Scoped, ConfigurationAIDeploymentSource>()); + services.TryAddEnumerable(ServiceDescriptor.Scoped, ConfigurationAIProviderConnectionSource>()); + services.TryAddSingleton(); if (!services.Any(descriptor => descriptor.ServiceType == typeof(EmbeddedResourceAIProfileTemplateProvider))) diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs similarity index 66% rename from src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs rename to src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs index 190ed149..69cfae8a 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs @@ -1,8 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; -using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; -using CrestApps.Core.Models; using CrestApps.Core.Services; using CrestApps.Core.Support; using Microsoft.Extensions.Configuration; @@ -10,152 +8,45 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Services; + /// -/// Decorates a persisted AI deployment store with configuration-backed deployments from appsettings.json. -/// Read operations return the merged result while write operations continue to target the persisted store only. +/// A read-only catalog source that reads AI deployments from application configuration +/// (e.g., appsettings.json). Registered with Order = 100 so that DB-backed sources +/// (Order = 0) take precedence when entries share the same name. /// -public sealed class ConfigurationAIDeploymentCatalog : IAIDeploymentStore +public sealed class ConfigurationAIDeploymentSource : INamedSourceCatalogSource { - private readonly INamedSourceCatalog _deploymentCatalog; private readonly IConfiguration _configuration; private readonly AIOptions _aiOptions; private readonly AIDeploymentCatalogOptions _catalogOptions; private readonly ILogger _logger; - public ConfigurationAIDeploymentCatalog( - INamedSourceCatalog deploymentCatalog, + public ConfigurationAIDeploymentSource( IConfiguration configuration, IOptions aiOptions, IOptions catalogOptions, - ILogger logger) + ILogger logger) { - _deploymentCatalog = deploymentCatalog; _configuration = configuration; _aiOptions = aiOptions.Value; _catalogOptions = catalogOptions.Value; _logger = logger; } - public async ValueTask FindByIdAsync(string id) - { - var result = await _deploymentCatalog.FindByIdAsync(id); - if (result != null) - { - return result; - } - - return (await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync())) - .FirstOrDefault(deployment => string.Equals(deployment.ItemId, id, StringComparison.OrdinalIgnoreCase)) - ?.Clone(); - } - - public async ValueTask> GetAllAsync() - { - var dbRecords = await _deploymentCatalog.GetAllAsync(); - var configRecords = await GetConfigDeploymentsAsync(dbRecords); - if (configRecords.Count == 0) - { - return dbRecords; - } - - return Merge(dbRecords, configRecords); - } - - public async ValueTask> GetAsync(IEnumerable ids) - { - var dbRecords = await _deploymentCatalog.GetAsync(ids); - var requestedIds = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); - var foundIds = dbRecords.Select(static deployment => deployment.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); - var missingIds = requestedIds.Except(foundIds).ToList(); - if (missingIds.Count == 0) - { - return dbRecords; - } - - var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => missingIds.Contains(deployment.ItemId)).ToList(); - if (configMatches.Count == 0) - { - return dbRecords; - } - - return Merge(dbRecords, configMatches); - } - - public async ValueTask> PageAsync(int page, int pageSize, TQuery context) - where TQuery : QueryContext - { - var configRecords = await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync()); - if (configRecords.Count == 0) - { - return await _deploymentCatalog.PageAsync(page, pageSize, context); - } - - var allRecords = await GetAllAsync(); - var filtered = ApplyFilters(context, allRecords); - var skip = (page - 1) * pageSize; - return new PageResult - { - Count = filtered.Count(), - Entries = filtered.Skip(skip).Take(pageSize).ToArray(), - }; - } - - public async ValueTask FindByNameAsync(string name) - { - var result = await _deploymentCatalog.FindByNameAsync(name); - if (result != null) - { - return result; - } - - return (await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync())) - .FirstOrDefault(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase)) - ?.Clone(); - } - - public async ValueTask> GetAsync(string source) - { - var dbRecords = await _deploymentCatalog.GetAsync(source); - var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)).ToList(); - if (configMatches.Count == 0) - { - return dbRecords; - } - - return Merge(dbRecords, configMatches); - } - - public async ValueTask GetAsync(string name, string source) - { - var result = await _deploymentCatalog.GetAsync(name, source); - if (result != null) - { - return result; - } - - return (await GetConfigDeploymentsAsync(await _deploymentCatalog.GetAllAsync())) - .FirstOrDefault(deployment => - string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase) && - string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)) - ?.Clone(); - } - - public ValueTask DeleteAsync(AIDeployment entry) => _deploymentCatalog.DeleteAsync(entry); - public ValueTask CreateAsync(AIDeployment entry) => _deploymentCatalog.CreateAsync(entry); - public ValueTask UpdateAsync(AIDeployment entry) => _deploymentCatalog.UpdateAsync(entry); + public int Order => 100; - private async Task> GetConfigDeploymentsAsync(IReadOnlyCollection storedDeployments) + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) { var deployments = new Dictionary(StringComparer.OrdinalIgnoreCase); - var names = storedDeployments + var names = knownEntries .Where(static deployment => !string.IsNullOrWhiteSpace(deployment.Name)) .ToDictionary(static deployment => deployment.Name, static deployment => deployment.ItemId, StringComparer.OrdinalIgnoreCase); if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Evaluating AI deployment configuration. Stored deployments: {StoredDeploymentCount}. Deployment sections: [{DeploymentSections}]", - storedDeployments.Count, + "Evaluating AI deployment configuration. Known entries: {KnownEntryCount}. Deployment sections: [{DeploymentSections}]", + knownEntries.Count, string.Join(", ", _catalogOptions.DeploymentSections)); } @@ -175,7 +66,7 @@ private async Task> GetConfigDeploymentsAsync( deployments.Count); } - return deployments.Values.ToArray(); + return ValueTask.FromResult>(deployments.Values); } private void ReadConfiguredDeployments(Dictionary deployments, Dictionary names) @@ -292,6 +183,7 @@ private static AIDeploymentConfigurationEntry ParseConfiguredDeploymentEntry(Jso ConnectionName = deploymentObject["ConnectionName"].GetStringValue(), Properties = BuildDeploymentProperties(deploymentObject), }; + if (TryGetDeploymentType(deploymentObject["Type"], out var deploymentType)) { entry.Type = deploymentType; @@ -349,6 +241,41 @@ private AIDeployment CreateConfiguredDeployment(AIDeploymentConfigurationEntry e }; } + private void AddDeployment( + Dictionary deployments, + Dictionary names, + AIDeployment deployment, + string sourceDescription) + { + if (deployment == null) + { + return; + } + + if (names.TryGetValue(deployment.Name, out var existingItemId) && + !string.Equals(existingItemId, deployment.ItemId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Skipping AI deployment '{DeploymentName}' from {SourceDescription} because another deployment with the same name is already defined.", + deployment.Name, + sourceDescription); + return; + } + + names[deployment.Name] = deployment.ItemId; + deployments[deployment.ItemId] = deployment; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Registered configuration-backed AI deployment '{DeploymentName}' from '{SourceDescription}' with item id '{DeploymentId}' and source '{DeploymentSource}'.", + deployment.Name, + sourceDescription, + deployment.ItemId, + deployment.Source); + } + } + private static JsonNode ReadConfigurationNode(IConfigurationSection section) { var children = section.GetChildren().ToArray(); @@ -448,41 +375,6 @@ private static JsonObject BuildDeploymentProperties(JsonObject deploymentObject) return properties; } - private void AddDeployment( - Dictionary deployments, - Dictionary names, - AIDeployment deployment, - string sourceDescription) - { - if (deployment == null) - { - return; - } - - if (names.TryGetValue(deployment.Name, out var existingItemId) && - !string.Equals(existingItemId, deployment.ItemId, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning( - "Skipping AI deployment '{DeploymentName}' from {SourceDescription} because another deployment with the same name is already defined.", - deployment.Name, - sourceDescription); - return; - } - - names[deployment.Name] = deployment.ItemId; - deployments[deployment.ItemId] = deployment; - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Registered configuration-backed AI deployment '{DeploymentName}' from '{SourceDescription}' with item id '{DeploymentId}' and source '{DeploymentSource}'.", - deployment.Name, - sourceDescription, - deployment.ItemId, - deployment.Source); - } - } - private static string GetNodeTypeName(JsonNode node) { return node switch @@ -494,45 +386,4 @@ private static string GetNodeTypeName(JsonNode node) _ => node.GetType().Name, }; } - - private static List Merge(IReadOnlyCollection primary, IReadOnlyCollection secondary) - { - var merged = new List(primary.Count + secondary.Count); - merged.AddRange(primary); - merged.AddRange(secondary); - return merged; - } - - private static List Merge(IReadOnlyCollection primary, List secondary) - { - var merged = new List(primary.Count + secondary.Count); - merged.AddRange(primary); - merged.AddRange(secondary); - return merged; - } - - private static IEnumerable ApplyFilters(QueryContext context, IEnumerable records) - { - if (context is null) - { - return records; - } - - if (!string.IsNullOrEmpty(context.Source)) - { - records = records.Where(deployment => string.Equals(deployment.Source, context.Source, StringComparison.OrdinalIgnoreCase)); - } - - if (!string.IsNullOrEmpty(context.Name)) - { - records = records.Where(deployment => deployment.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); - } - - if (context.Sorted) - { - records = records.OrderBy(static deployment => deployment.Name, StringComparer.OrdinalIgnoreCase); - } - - return records; - } } diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs similarity index 56% rename from src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs rename to src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs index 71784888..ce4e5c8f 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs @@ -1,170 +1,40 @@ using System.Globalization; using CrestApps.Core.AI.Models; using CrestApps.Core.Infrastructure; -using CrestApps.Core.Models; using CrestApps.Core.Services; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Services; /// -/// Decorates a persisted AI provider connection store with configuration-backed -/// connections from appsettings.json. Read operations return the merged result -/// while write operations continue to target the persisted store only. +/// A read-only catalog source that reads AI provider connections from application +/// configuration (e.g., appsettings.json). Registered with Order = 100 so that +/// DB-backed sources (Order = 0) take precedence when entries share the same name. /// -public sealed class ConfigurationAIProviderConnectionCatalog : INamedSourceCatalog +public sealed class ConfigurationAIProviderConnectionSource : INamedSourceCatalogSource { - public const string PersistedCatalogKey = "PersistedCatalog"; - - private readonly INamedSourceCatalog _inner; private readonly IConfiguration _configuration; private readonly AIProviderConnectionCatalogOptions _options; private readonly ILogger _logger; - public ConfigurationAIProviderConnectionCatalog( - [FromKeyedServices(PersistedCatalogKey)] INamedSourceCatalog inner, + public ConfigurationAIProviderConnectionSource( IConfiguration configuration, IOptions options, - ILogger logger) + ILogger logger) { - _inner = inner; _configuration = configuration; _options = options.Value; _logger = logger; } - public async ValueTask FindByIdAsync(string id) - { - var result = await _inner.FindByIdAsync(id); - if (result != null) - { - return result; - } - - return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync())) - .FirstOrDefault(connection => string.Equals(connection.ItemId, id, StringComparison.OrdinalIgnoreCase)) - ?.Clone(); - } - - public async ValueTask> GetAllAsync() - { - var storedConnections = await _inner.GetAllAsync(); - var configuredConnections = await GetConfiguredConnectionsAsync(storedConnections); - - if (configuredConnections.Count == 0) - { - return storedConnections; - } - - var merged = new List(storedConnections.Count + configuredConnections.Count); - merged.AddRange(storedConnections); - merged.AddRange(configuredConnections); - - return merged; - } - - public async ValueTask> GetAsync(IEnumerable ids) - { - var storedConnections = await _inner.GetAsync(ids); - var requestedIds = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); - var foundIds = storedConnections.Select(static connection => connection.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); - var missingIds = requestedIds.Except(foundIds).ToList(); - - if (missingIds.Count == 0) - { - return storedConnections; - } - - var configuredConnections = (await GetConfiguredConnectionsAsync(storedConnections)) - .Where(connection => missingIds.Contains(connection.ItemId)) - .ToArray(); - - if (configuredConnections.Length == 0) - { - return storedConnections; - } - - var merged = new List(storedConnections.Count + configuredConnections.Length); - merged.AddRange(storedConnections); - merged.AddRange(configuredConnections); - - return merged; - } - - public async ValueTask> PageAsync(int page, int pageSize, TQuery context) - where TQuery : QueryContext - { - var allConnections = await GetAllAsync(); - var filtered = ApplyFilters(context, allConnections); - var skip = (page - 1) * pageSize; - - return new PageResult - { - Count = filtered.Count(), - Entries = filtered.Skip(skip).Take(pageSize).ToArray(), - }; - } - - public async ValueTask FindByNameAsync(string name) - { - var result = await _inner.FindByNameAsync(name); - if (result != null) - { - return result; - } - - return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync())) - .FirstOrDefault(connection => string.Equals(connection.Name, name, StringComparison.OrdinalIgnoreCase)) - ?.Clone(); - } - - public async ValueTask> GetAsync(string source) - { - var storedConnections = await _inner.GetAsync(source); - var configuredConnections = (await GetConfiguredConnectionsAsync(storedConnections)) - .Where(connection => string.Equals(connection.Source, source, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (configuredConnections.Length == 0) - { - return storedConnections; - } - - var merged = new List(storedConnections.Count + configuredConnections.Length); - merged.AddRange(storedConnections); - merged.AddRange(configuredConnections); + public int Order => 100; - return merged; - } - - public async ValueTask GetAsync(string name, string source) - { - var result = await _inner.GetAsync(name, source); - if (result != null) - { - return result; - } - - return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync())) - .FirstOrDefault(connection => - string.Equals(connection.Name, name, StringComparison.OrdinalIgnoreCase) && - string.Equals(connection.Source, source, StringComparison.OrdinalIgnoreCase)) - ?.Clone(); - } - - public ValueTask DeleteAsync(AIProviderConnection entry) => _inner.DeleteAsync(entry); - - public ValueTask CreateAsync(AIProviderConnection entry) => _inner.CreateAsync(entry); - - public ValueTask UpdateAsync(AIProviderConnection entry) => _inner.UpdateAsync(entry); - - private Task> GetConfiguredConnectionsAsync(IReadOnlyCollection storedConnections) + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) { var connections = new Dictionary(StringComparer.OrdinalIgnoreCase); - var names = storedConnections + var names = knownEntries .Where(static connection => !string.IsNullOrWhiteSpace(connection.Name)) .ToDictionary(static connection => connection.Name, static connection => connection.ItemId, StringComparer.OrdinalIgnoreCase); @@ -185,7 +55,7 @@ private Task> GetConfiguredConnections _logger.LogError(ex, "Error reading AI provider connection configuration."); } - return Task.FromResult>(connections.Values.ToArray()); + return ValueTask.FromResult>(connections.Values); } private void ReadTopLevelConnections(string sectionPath, Dictionary connections, Dictionary names) @@ -365,29 +235,4 @@ private static object ParseScalar(string value) return value; } - - private static IEnumerable ApplyFilters(QueryContext context, IEnumerable records) - { - if (context is null) - { - return records; - } - - if (!string.IsNullOrEmpty(context.Source)) - { - records = records.Where(connection => string.Equals(connection.Source, context.Source, StringComparison.OrdinalIgnoreCase)); - } - - if (!string.IsNullOrEmpty(context.Name)) - { - records = records.Where(connection => connection.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); - } - - if (context.Sorted) - { - records = records.OrderBy(static connection => connection.DisplayText ?? connection.Name, StringComparer.OrdinalIgnoreCase); - } - - return records; - } } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentStore.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentStore.cs new file mode 100644 index 00000000..77fbab50 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentStore.cs @@ -0,0 +1,20 @@ +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Services; + +/// +/// The default multi-source AI deployment store. Aggregates entries from all +/// registered implementations +/// (configuration, YesSql, EntityCore, or custom sources). +/// +public sealed class DefaultAIDeploymentStore : MultiSourceNamedSourceCatalog, IAIDeploymentStore +{ + public DefaultAIDeploymentStore(IEnumerable> sources) + : base(sources) + { + } + + protected override string GetItemId(AIDeployment entry) => entry.ItemId; +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProviderConnectionStore.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProviderConnectionStore.cs new file mode 100644 index 00000000..e291a3b5 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIProviderConnectionStore.cs @@ -0,0 +1,48 @@ +using CrestApps.Core.AI.Connections; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.AI.Services; + +/// +/// The default multi-source AI provider connection store. Aggregates entries from all +/// registered implementations +/// (configuration, YesSql, EntityCore, or custom sources). +/// +public sealed class DefaultAIProviderConnectionStore : MultiSourceNamedSourceCatalog, IAIProviderConnectionStore +{ + public DefaultAIProviderConnectionStore(IEnumerable> sources) + : base(sources) + { + } + + protected override string GetItemId(AIProviderConnection entry) => entry.ItemId; + + protected override IEnumerable ApplyFilters(QueryContext context, IEnumerable entries) + { + if (context is null) + { + return entries; + } + + if (!string.IsNullOrEmpty(context.Source)) + { + entries = entries.Where(entry => string.Equals(entry.Source, context.Source, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(context.Name)) + { + entries = entries.Where(entry => entry.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (context.Sorted) + { + entries = entries.OrderBy(static entry => entry.DisplayText ?? entry.Name, StringComparer.OrdinalIgnoreCase); + } + + return entries; + } + + protected override string GetSortKey(AIProviderConnection entry) => entry.DisplayText ?? entry.Name; +} diff --git a/src/Primitives/CrestApps.Core/Services/MultiSourceNamedCatalog.cs b/src/Primitives/CrestApps.Core/Services/MultiSourceNamedCatalog.cs new file mode 100644 index 00000000..62a0ca11 --- /dev/null +++ b/src/Primitives/CrestApps.Core/Services/MultiSourceNamedCatalog.cs @@ -0,0 +1,158 @@ +using CrestApps.Core.Models; + +namespace CrestApps.Core.Services; + +/// +/// A base class that aggregates entries from multiple +/// implementations, deduplicating by name (lower-order sources win). Write operations are +/// delegated to the first found. +/// +/// The type of catalog entry. +public abstract class MultiSourceNamedCatalog : INamedCatalog + where T : INameAwareModel +{ + private readonly IEnumerable> _sources; + private readonly IWritableNamedCatalogSource _writableSource; + + protected MultiSourceNamedCatalog(IEnumerable> sources) + { + _sources = sources.OrderBy(static source => source.Order); + _writableSource = sources + .OfType>() + .OrderBy(static source => source.Order) + .FirstOrDefault(); + } + + public async ValueTask> GetAllAsync() + { + return await GetMergedEntriesAsync(); + } + + public async ValueTask FindByIdAsync(string id) + { + var entries = await GetMergedEntriesAsync(); + + return entries.FirstOrDefault(entry => string.Equals(GetItemId(entry), id, StringComparison.OrdinalIgnoreCase)); + } + + public async ValueTask FindByNameAsync(string name) + { + var entries = await GetMergedEntriesAsync(); + + return entries.FirstOrDefault(entry => string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + public async ValueTask> GetAsync(IEnumerable ids) + { + var idSet = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); + var entries = await GetMergedEntriesAsync(); + + return entries.Where(entry => idSet.Contains(GetItemId(entry))).ToArray(); + } + + public async ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + var entries = await GetMergedEntriesAsync(); + var filtered = ApplyFilters(context, entries).ToList(); + var skip = (page - 1) * pageSize; + + return new PageResult + { + Count = filtered.Count, + Entries = filtered.Skip(skip).Take(pageSize).ToArray(), + }; + } + + public ValueTask DeleteAsync(T entry) + { + EnsureWritableSource(); + + return _writableSource.DeleteAsync(entry); + } + + public ValueTask CreateAsync(T entry) + { + EnsureWritableSource(); + + return _writableSource.CreateAsync(entry); + } + + public ValueTask UpdateAsync(T entry) + { + EnsureWritableSource(); + + return _writableSource.UpdateAsync(entry); + } + + /// + /// Gets the unique identifier for an entry. + /// Override in derived classes when the identifier is stored in a different property. + /// + protected abstract string GetItemId(T entry); + + /// + /// Applies query-context filters to the entries. + /// Override in derived classes to customize filtering and sorting behavior. + /// + protected virtual IEnumerable ApplyFilters(QueryContext context, IEnumerable entries) + { + if (context is null) + { + return entries; + } + + if (!string.IsNullOrEmpty(context.Name)) + { + entries = entries.Where(entry => entry.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (context.Sorted) + { + entries = entries.OrderBy(GetSortKey, StringComparer.OrdinalIgnoreCase); + } + + return entries; + } + + /// + /// Returns the sort key for an entry. Defaults to . + /// Override in derived classes to customize sort order. + /// + protected virtual string GetSortKey(T entry) => entry.Name; + + private async ValueTask> GetMergedEntriesAsync() + { + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var merged = new List(); + + foreach (var source in _sources) + { + var entries = await source.GetEntriesAsync(merged); + + foreach (var entry in entries) + { + if (!string.IsNullOrWhiteSpace(entry.Name) && !seenNames.Add(entry.Name)) + { + continue; + } + + merged.Add(entry); + } + } + + return merged; + } + + private void EnsureWritableSource() + { + if (_writableSource is null) + { + throw new InvalidOperationException( + $""" + No writable source is registered for {typeof(T).Name}. + Register an {nameof(IWritableNamedCatalogSource<>)} implementation to enable write operations. + """); + } + } +} diff --git a/src/Primitives/CrestApps.Core/Services/MultiSourceNamedSourceCatalog.cs b/src/Primitives/CrestApps.Core/Services/MultiSourceNamedSourceCatalog.cs new file mode 100644 index 00000000..122d0a25 --- /dev/null +++ b/src/Primitives/CrestApps.Core/Services/MultiSourceNamedSourceCatalog.cs @@ -0,0 +1,181 @@ +using CrestApps.Core.Models; + +namespace CrestApps.Core.Services; + +/// +/// A base class that aggregates entries from multiple +/// implementations, deduplicating by name (lower-order sources win). Write operations are +/// delegated to the first found. +/// +/// The type of catalog entry. +public abstract class MultiSourceNamedSourceCatalog : INamedSourceCatalog + where T : INameAwareModel, ISourceAwareModel +{ + private readonly IEnumerable> _sources; + private readonly IWritableNamedSourceCatalogSource _writableSource; + + protected MultiSourceNamedSourceCatalog(IEnumerable> sources) + { + _sources = sources.OrderBy(static source => source.Order); + _writableSource = sources + .OfType>() + .OrderBy(static source => source.Order) + .FirstOrDefault(); + } + + public async ValueTask> GetAllAsync() + { + return await GetMergedEntriesAsync(); + } + + public async ValueTask FindByIdAsync(string id) + { + var entries = await GetMergedEntriesAsync(); + + return entries.FirstOrDefault(entry => string.Equals(GetItemId(entry), id, StringComparison.OrdinalIgnoreCase)); + } + + public async ValueTask FindByNameAsync(string name) + { + var entries = await GetMergedEntriesAsync(); + + return entries.FirstOrDefault(entry => string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + public async ValueTask> GetAsync(IEnumerable ids) + { + var idSet = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); + var entries = await GetMergedEntriesAsync(); + + return entries.Where(entry => idSet.Contains(GetItemId(entry))).ToArray(); + } + + public async ValueTask> GetAsync(string source) + { + var entries = await GetMergedEntriesAsync(); + + return entries + .Where(entry => string.Equals(entry.Source, source, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + public async ValueTask GetAsync(string name, string source) + { + var entries = await GetMergedEntriesAsync(); + + return entries.FirstOrDefault(entry => + string.Equals(entry.Name, name, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Source, source, StringComparison.OrdinalIgnoreCase)); + } + + public async ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + var entries = await GetMergedEntriesAsync(); + var filtered = ApplyFilters(context, entries).ToList(); + var skip = (page - 1) * pageSize; + + return new PageResult + { + Count = filtered.Count, + Entries = filtered.Skip(skip).Take(pageSize).ToArray(), + }; + } + + public ValueTask DeleteAsync(T entry) + { + EnsureWritableSource(); + + return _writableSource.DeleteAsync(entry); + } + + public ValueTask CreateAsync(T entry) + { + EnsureWritableSource(); + + return _writableSource.CreateAsync(entry); + } + + public ValueTask UpdateAsync(T entry) + { + EnsureWritableSource(); + + return _writableSource.UpdateAsync(entry); + } + + /// + /// Gets the unique identifier for an entry. + /// Override in derived classes when the identifier is stored in a different property. + /// + protected abstract string GetItemId(T entry); + + /// + /// Applies query-context filters to the entries. + /// Override in derived classes to customize filtering and sorting behavior. + /// + protected virtual IEnumerable ApplyFilters(QueryContext context, IEnumerable entries) + { + if (context is null) + { + return entries; + } + + if (!string.IsNullOrEmpty(context.Source)) + { + entries = entries.Where(entry => string.Equals(entry.Source, context.Source, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(context.Name)) + { + entries = entries.Where(entry => entry.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (context.Sorted) + { + entries = entries.OrderBy(GetSortKey, StringComparer.OrdinalIgnoreCase); + } + + return entries; + } + + /// + /// Returns the sort key for an entry. Defaults to . + /// Override in derived classes to customize sort order. + /// + protected virtual string GetSortKey(T entry) => entry.Name; + + private async ValueTask> GetMergedEntriesAsync() + { + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var merged = new List(); + + foreach (var source in _sources) + { + var entries = await source.GetEntriesAsync(merged); + + foreach (var entry in entries) + { + if (!string.IsNullOrWhiteSpace(entry.Name) && !seenNames.Add(entry.Name)) + { + continue; + } + + merged.Add(entry); + } + } + + return merged; + } + + private void EnsureWritableSource() + { + if (_writableSource is null) + { + throw new InvalidOperationException( + $""" + No writable source is registered for {typeof(T).Name}. + Register an {nameof(IWritableNamedSourceCatalogSource<>)} implementation to enable write operations. + """); + } + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index e10e1dfc..268c5151 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -38,12 +38,9 @@ using CrestApps.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.Data.Sqlite; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; using YesSql; using YesSql.Provider.Sqlite; using YesSql.Sql; -using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration; namespace CrestApps.Core.Mvc.Web.Services; @@ -70,7 +67,6 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection Data.YesSql.ServiceCollectionExtensions.AddCoreYesSqlDataStore(services, configuration => configuration.UseSqLite(connectionStringBuilder.ToString()).SetTablePrefix("CA_")); // YesSql-backed catalogs and managers. services.AddYesSqlNamedSourceDocumentCatalog() - .AddYesSqlNamedSourceDocumentCatalog() .AddYesSqlDocumentCatalog() .AddYesSqlSourceDocumentCatalog() .AddYesSqlNamedDocumentCatalog() @@ -115,20 +111,11 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection .AddScoped, ArticleIndexingHandler>() .AddScoped(); - services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey) - .AddYesSqlNamedSourceDocumentCatalog(); - - services.AddScoped, YesSqlAIDeploymentStore>(); - services.AddScoped(sp => - new ConfigurationAIDeploymentCatalog( - sp.GetRequiredService>(), - sp.GetService() ?? new ConfigurationBuilder().Build(), - sp.GetService>() ?? Options.Create(new AIOptions()), - sp.GetService>() ?? Options.Create(new AIDeploymentCatalogOptions()), - sp.GetService>() ?? NullLogger.Instance)); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); + // AI provider connections: wrap YesSql catalog as a writable multi-source binding source. + services.AddYesSqlNamedSourceBindingSource(); + + // AI deployments: wrap YesSql catalog as a writable multi-source binding source. + services.AddYesSqlNamedSourceBindingSource(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 7d58773a..aa3904a6 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -2,22 +2,18 @@ using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.DataSources; -using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; using CrestApps.Core.Builders; using CrestApps.Core.Data.EntityCore.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Models; using CrestApps.Core.Services; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace CrestApps.Core.Data.EntityCore; @@ -86,20 +82,41 @@ public static IServiceCollection AddEntityCoreStores(this IServiceCollection ser services.AddScoped(); services.AddScoped>(sp => sp.GetRequiredService()); - services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey); - services.AddNamedSourceDocumentCatalog(); - - services.AddScoped, EntityCoreAIDeploymentStore>(); - services.AddScoped(sp => - new ConfigurationAIDeploymentCatalog( - sp.GetRequiredService>(), - sp.GetService() ?? new ConfigurationBuilder().Build(), - sp.GetService>() ?? Options.Create(new AIOptions()), - sp.GetService>() ?? Options.Create(new AIDeploymentCatalogOptions()), - sp.GetService>() ?? NullLogger.Instance)); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); + // AI provider connections: wrap EntityCore catalog as a writable multi-source binding source. + services.AddEntityCoreNamedSourceBindingSource(); + + // AI deployments: wrap EntityCore catalog as a writable multi-source binding source. + services.AddEntityCoreNamedSourceBindingSource(); + + return services; + } + + /// + /// Registers an EntityCore-backed + /// as an binding source for the + /// multi-source store pattern. + /// + public static IServiceCollection AddEntityCoreNamedSourceBindingSource(this IServiceCollection services) + where TModel : SourceCatalogEntry, INameAwareModel + { + services.AddScoped>(); + services.AddScoped>(sp => + new WritableCatalogBindingSource(sp.GetRequiredService>())); + + return services; + } + + /// + /// Registers an EntityCore-backed + /// as an binding source for the + /// multi-source store pattern. + /// + public static IServiceCollection AddEntityCoreNamedBindingSource(this IServiceCollection services) + where TModel : CatalogItem, INameAwareModel + { + services.AddScoped>(); + services.AddScoped>(sp => + new WritableNamedCatalogBindingSource(sp.GetRequiredService>())); return services; } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 96c8f37a..f0a94037 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -185,4 +185,36 @@ public static IServiceCollection AddYesSqlNamedSourceDocumentCatalog + /// Registers a YesSql-backed + /// as an binding source for the + /// multi-source store pattern. + /// + public static IServiceCollection AddYesSqlNamedSourceBindingSource(this IServiceCollection services) + where TModel : CatalogItem, INameAwareModel, ISourceAwareModel + where TIndex : CatalogItemIndex, INameAwareIndex, ISourceAwareIndex + { + services.AddScoped>(); + services.AddScoped>(sp => + new WritableCatalogBindingSource(sp.GetRequiredService>())); + + return services; + } + + /// + /// Registers a YesSql-backed + /// as an binding source for the + /// multi-source store pattern. + /// + public static IServiceCollection AddYesSqlNamedBindingSource(this IServiceCollection services) + where TModel : CatalogItem, INameAwareModel + where TIndex : CatalogItemIndex, INameAwareIndex + { + services.AddScoped>(); + services.AddScoped>(sp => + new WritableNamedCatalogBindingSource(sp.GetRequiredService>())); + + return services; + } } diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/CosineSimilarityTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/CosineSimilarityTests.cs new file mode 100644 index 00000000..5c99c093 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Core/Mcp/CosineSimilarityTests.cs @@ -0,0 +1,117 @@ +using CrestApps.Core.AI.Mcp.Services; + +namespace CrestApps.OrchardCore.Tests.Core.Mcp; + +public sealed class CosineSimilarityTests +{ + [Fact] + public void DotProduct_IdenticalNormalizedVectors_ReturnsOne() + { + var vector = DefaultMcpCapabilityResolver.NormalizeL2([1f, 2f, 3f]); + + var result = DefaultMcpCapabilityResolver.DotProduct(vector, vector); + + Assert.Equal(1f, result, precision: 5); + } + + [Fact] + public void DotProduct_OrthogonalVectors_ReturnsZero() + { + var vectorA = DefaultMcpCapabilityResolver.NormalizeL2([1f, 0f]); + var vectorB = DefaultMcpCapabilityResolver.NormalizeL2([0f, 1f]); + + var result = DefaultMcpCapabilityResolver.DotProduct(vectorA, vectorB); + + Assert.Equal(0f, result, precision: 5); + } + + [Fact] + public void DotProduct_OppositeNormalizedVectors_ReturnsNegativeOne() + { + var vectorA = DefaultMcpCapabilityResolver.NormalizeL2([1f, 0f, 0f]); + var vectorB = DefaultMcpCapabilityResolver.NormalizeL2([-1f, 0f, 0f]); + + var result = DefaultMcpCapabilityResolver.DotProduct(vectorA, vectorB); + + Assert.Equal(-1f, result, precision: 5); + } + + [Fact] + public void DotProduct_EmptyVectors_ReturnsZero() + { + var result = DefaultMcpCapabilityResolver.DotProduct([], []); + + Assert.Equal(0f, result); + } + + [Fact] + public void DotProduct_DifferentLengths_ReturnsZero() + { + var vectorA = new float[] { 1f, 2f }; + var vectorB = new float[] { 1f, 2f, 3f }; + + var result = DefaultMcpCapabilityResolver.DotProduct(vectorA, vectorB); + + Assert.Equal(0f, result); + } + + [Fact] + public void DotProduct_ZeroVectors_ReturnsZero() + { + var vectorA = new float[] { 0f, 0f, 0f }; + var vectorB = new float[] { 0f, 0f, 0f }; + + var result = DefaultMcpCapabilityResolver.DotProduct(vectorA, vectorB); + + Assert.Equal(0f, result); + } + + [Fact] + public void DotProduct_SimilarNormalizedVectors_ReturnsHighValue() + { + var vectorA = DefaultMcpCapabilityResolver.NormalizeL2([1f, 2f, 3f]); + var vectorB = DefaultMcpCapabilityResolver.NormalizeL2([1.1f, 2.1f, 3.1f]); + + var result = DefaultMcpCapabilityResolver.DotProduct(vectorA, vectorB); + + Assert.True(result > 0.99f, $"Expected high similarity, got {result}"); + } + + [Fact] + public void DotProduct_ScaledNormalizedVectors_ReturnsOne() + { + // Scaled vectors have the same direction, so after normalization they're identical. + var vectorA = DefaultMcpCapabilityResolver.NormalizeL2([1f, 2f, 3f]); + var vectorB = DefaultMcpCapabilityResolver.NormalizeL2([2f, 4f, 6f]); + + var result = DefaultMcpCapabilityResolver.DotProduct(vectorA, vectorB); + + Assert.Equal(1f, result, precision: 5); + } + + [Fact] + public void NormalizeL2_ProducesUnitVector() + { + var vector = new float[] { 3f, 4f }; + + var normalized = DefaultMcpCapabilityResolver.NormalizeL2(vector); + + // Magnitude should be 1. + var magnitude = MathF.Sqrt(normalized[0] * normalized[0] + normalized[1] * normalized[1]); + Assert.Equal(1f, magnitude, precision: 5); + + // Direction preserved: 3/5, 4/5. + Assert.Equal(0.6f, normalized[0], precision: 5); + Assert.Equal(0.8f, normalized[1], precision: 5); + } + + [Fact] + public void NormalizeL2_ZeroVector_ReturnsZeroVector() + { + var vector = new float[] { 0f, 0f, 0f }; + + var normalized = DefaultMcpCapabilityResolver.NormalizeL2(vector); + + Assert.All(normalized, v => Assert.Equal(0f, v)); + } +} diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/DefaultMcpMetadataPromptGeneratorTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/DefaultMcpMetadataPromptGeneratorTests.cs new file mode 100644 index 00000000..3db82221 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Core/Mcp/DefaultMcpMetadataPromptGeneratorTests.cs @@ -0,0 +1,271 @@ +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.AI.Mcp.Services; + +namespace CrestApps.OrchardCore.Tests.Core.Mcp; + +public sealed class DefaultMcpMetadataPromptGeneratorTests +{ + private readonly DefaultMcpMetadataPromptGenerator _generator = new(); + + [Fact] + public void Generate_WithNullCapabilities_ReturnsNull() + { + var result = _generator.Generate(null); + + Assert.Null(result); + } + + [Fact] + public void Generate_WithEmptyCapabilities_ReturnsNull() + { + var result = _generator.Generate([]); + + Assert.Null(result); + } + + [Fact] + public void Generate_WithNoActualCapabilities_ReturnsNull() + { + var capabilities = new List + { + new() + { + ConnectionId = "conn1", + ConnectionDisplayText = "Server 1", + Tools = [], + Prompts = [], + Resources = [], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.Null(result); + } + + [Fact] + public void Generate_WithTools_IncludesToolsSection() + { + var capabilities = new List + { + new() + { + ConnectionId = "conn1", + ConnectionDisplayText = "My Server", + Tools = + [ + new McpServerCapability + { + Name = "search", + Description = "Search the web", + }, + ], + Prompts = [], + Resources = [], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.Contains("mcp_invoke", result); + Assert.Contains("My Server", result); + Assert.Contains("conn1", result); + Assert.Contains("Tools (pass required arguments via 'inputs'):", result); + Assert.Contains("search", result); + Assert.Contains("Search the web", result); + } + + [Fact] + public void Generate_WithPrompts_IncludesPromptsSection() + { + var capabilities = new List + { + new() + { + ConnectionId = "conn1", + ConnectionDisplayText = "Server", + Tools = [], + Prompts = + [ + new McpServerCapability + { + Name = "summarize", + Description = "Summarize text", + }, + ], + Resources = [], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.Contains("Prompts:", result); + Assert.Contains("summarize", result); + Assert.Contains("Summarize text", result); + } + + [Fact] + public void Generate_WithResources_IncludesResourcesSection() + { + var capabilities = new List + { + new() + { + ConnectionId = "conn1", + ConnectionDisplayText = "Server", + Tools = [], + Prompts = [], + Resources = + [ + new McpServerCapability + { + Name = "docs", + Description = "Documentation files", + Uri = "file://docs/readme.md", + }, + ], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.Contains("Resources (use the URI as 'id' when invoking):", result); + Assert.Contains("file://docs/readme.md", result); + Assert.Contains("Documentation files", result); + } + + [Fact] + public void Generate_WithMultipleServers_IncludesAllServers() + { + var capabilities = new List + { + new() + { + ConnectionId = "conn1", + ConnectionDisplayText = "Server A", + Tools = + [ + new McpServerCapability { Name = "toolA" }, + ], + Prompts = [], + Resources = [], + }, + new() + { + ConnectionId = "conn2", + ConnectionDisplayText = "Server B", + Tools = + [ + new McpServerCapability { Name = "toolB" }, + ], + Prompts = [], + Resources = [], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.Contains("Server A", result); + Assert.Contains("conn1", result); + Assert.Contains("toolA", result); + Assert.Contains("Server B", result); + Assert.Contains("conn2", result); + Assert.Contains("toolB", result); + } + + [Fact] + public void Generate_SkipsEmptyServers() + { + var capabilities = new List + { + new() + { + ConnectionId = "empty", + ConnectionDisplayText = "Empty Server", + Tools = [], + Prompts = [], + Resources = [], + }, + new() + { + ConnectionId = "active", + ConnectionDisplayText = "Active Server", + Tools = + [ + new McpServerCapability { Name = "myTool" }, + ], + Prompts = [], + Resources = [], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.DoesNotContain("Empty Server", result); + Assert.Contains("Active Server", result); + } + + [Fact] + public void Generate_UsesConnectionIdWhenDisplayTextIsNull() + { + var capabilities = new List + { + new() + { + ConnectionId = "conn-id-123", + ConnectionDisplayText = null, + Tools = + [ + new McpServerCapability { Name = "tool1" }, + ], + Prompts = [], + Resources = [], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.Contains("conn-id-123", result); + } + + [Fact] + public void Generate_WithAllCapabilityTypes_IncludesAllSections() + { + var capabilities = new List + { + new() + { + ConnectionId = "full", + ConnectionDisplayText = "Full Server", + Tools = + [ + new McpServerCapability { Name = "calc" }, + ], + Prompts = + [ + new McpServerCapability { Name = "greet" }, + ], + Resources = + [ + new McpServerCapability { Name = "data", Uri = "file://data" }, + ], + }, + }; + + var result = _generator.Generate(capabilities); + + Assert.NotNull(result); + Assert.Contains("Tools (pass required arguments via 'inputs'):", result); + Assert.Contains("calc", result); + Assert.Contains("Prompts:", result); + Assert.Contains("greet", result); + Assert.Contains("Resources (use the URI as 'id' when invoking):", result); + Assert.Contains("file://data", result); + } +} diff --git a/tests/CrestApps.Core.Tests/Core/Mcp/InMemoryMcpCapabilityEmbeddingCacheTests.cs b/tests/CrestApps.Core.Tests/Core/Mcp/InMemoryMcpCapabilityEmbeddingCacheTests.cs new file mode 100644 index 00000000..4942b1f4 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Core/Mcp/InMemoryMcpCapabilityEmbeddingCacheTests.cs @@ -0,0 +1,185 @@ +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.AI.Mcp.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CrestApps.OrchardCore.Tests.Core.Mcp; + +public sealed class InMemoryMcpCapabilityEmbeddingCacheTests +{ + private readonly InMemoryMcpCapabilityEmbeddingCacheProvider _cache = new(NullLogger.Instance); + + [Fact] + public async Task GetOrCreateEmbeddingsAsync_WithEmptyCapabilities_ReturnsEmpty() + { + var generator = new FakeEmbeddingGenerator([]); + + var result = await _cache.GetOrCreateEmbeddingsAsync([], generator, TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task GetOrCreateEmbeddingsAsync_GeneratesEmbeddings_ForAllCapabilities() + { + var capabilities = new List + { + CreateCapabilities("conn1", "Server A", + tools: [new McpServerCapability { Name = "tool1", Description = "A tool" }], + prompts: [new McpServerCapability { Name = "prompt1", Description = "A prompt" }]), + }; + + var generator = new FakeEmbeddingGenerator(new float[] { 0.1f, 0.2f, 0.3f }); + + var result = await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + Assert.All(result, entry => + { + Assert.Equal("conn1", entry.ConnectionId); + Assert.Equal("Server A", entry.ConnectionDisplayText); + Assert.Equal(3, entry.Embedding.Length); + }); + } + + [Fact] + public async Task GetOrCreateEmbeddingsAsync_CachesResults_OnSecondCall() + { + var capabilities = new List + { + CreateCapabilities("conn1", "Server A", + tools: [new McpServerCapability { Name = "tool1", Description = "A tool" }]), + }; + + var callCount = 0; + var generator = new FakeEmbeddingGenerator(new float[] { 1f, 2f }, () => callCount++); + + var result1 = await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + var result2 = await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + + Assert.Single(result1); + Assert.Single(result2); + Assert.Equal(1, callCount); // Only called once + } + + [Fact] + public async Task Invalidate_ClearsCache_ForConnection() + { + var capabilities = new List + { + CreateCapabilities("conn1", "Server A", + tools: [new McpServerCapability { Name = "tool1", Description = "A tool" }]), + }; + + var callCount = 0; + var generator = new FakeEmbeddingGenerator([1f], () => callCount++); + + await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + _cache.Invalidate("conn1"); + await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + + Assert.Equal(2, callCount); // Called twice due to invalidation + } + + [Fact] + public async Task GetOrCreateEmbeddingsAsync_SkipsCapabilities_WithoutName() + { + var capabilities = new List + { + CreateCapabilities("conn1", "Server A", + tools: + [ + new McpServerCapability { Name = null, Description = "No name" }, + new McpServerCapability { Name = " ", Description = "Whitespace name" }, + new McpServerCapability { Name = "valid_tool", Description = "Valid" }, + ]), + }; + + var generator = new FakeEmbeddingGenerator(new float[] { 1f }); + + var result = await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + + Assert.Single(result); + Assert.Equal("valid_tool", result[0].CapabilityName); + } + + [Fact] + public async Task GetOrCreateEmbeddingsAsync_SetsCorrectCapabilityTypes() + { + var capabilities = new List + { + CreateCapabilities("conn1", "Server A", + tools: [new McpServerCapability { Name = "t1" }], + prompts: [new McpServerCapability { Name = "p1" }], + resources: [new McpServerCapability { Name = "r1" }]), + }; + + var generator = new FakeEmbeddingGenerator(new float[] { 1f }); + + var result = await _cache.GetOrCreateEmbeddingsAsync(capabilities, generator, TestContext.Current.CancellationToken); + + Assert.Equal(3, result.Count); + Assert.Contains(result, e => e.CapabilityType == McpCapabilityType.Tool && e.CapabilityName == "t1"); + Assert.Contains(result, e => e.CapabilityType == McpCapabilityType.Prompt && e.CapabilityName == "p1"); + Assert.Contains(result, e => e.CapabilityType == McpCapabilityType.Resource && e.CapabilityName == "r1"); + } + + private static McpServerCapabilities CreateCapabilities( + string connectionId, + string displayText, + IReadOnlyList tools = null, + IReadOnlyList prompts = null, + IReadOnlyList resources = null) + { + return new McpServerCapabilities + { + ConnectionId = connectionId, + ConnectionDisplayText = displayText, + Tools = tools ?? [], + Prompts = prompts ?? [], + Resources = resources ?? [], + IsHealthy = true, + FetchedUtc = DateTime.UtcNow, + }; + } + /// + /// A fake embedding generator that returns a fixed embedding vector for each input. + /// + private sealed class FakeEmbeddingGenerator : IEmbeddingGenerator> + { + private readonly float[] _fixedVector; + private readonly Action _onGenerate; + + public FakeEmbeddingGenerator(float[] fixedVector, Action onGenerate = null) + { + _fixedVector = fixedVector; + _onGenerate = onGenerate; + } + + public EmbeddingGeneratorMetadata Metadata { get; } = new("fake"); + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions options = null, + CancellationToken cancellationToken = default) + { + _onGenerate?.Invoke(); + + var inputs = values.ToList(); + var embeddings = new GeneratedEmbeddings>(); + + foreach (var _ in inputs) + { + embeddings.Add(new Embedding(_fixedVector)); + } + + return Task.FromResult(embeddings); + } + + public object GetService(Type serviceType, object serviceKey = null) => null; + + public void Dispose() + { + } + } +} diff --git a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs index e73e141c..dc498530 100644 --- a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs +++ b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs @@ -10,6 +10,7 @@ using CrestApps.Core.Models; using CrestApps.Core.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace CrestApps.Core.Tests; @@ -209,7 +210,9 @@ public static async Task CreateAsync() var services = new ServiceCollection(); services.AddHttpContextAccessor(); + services.AddLogging(); services.AddSingleton(TimeProvider.System); + services.AddCoreAIServices(); services.AddCoreEntityCoreSqliteDataStore($"Data Source={databasePath}"); services.AddEntityCoreStores(); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs index 431fd525..dd4b70df 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs @@ -97,7 +97,7 @@ public void AddCrestAppsAI_WhenAzureOpenAIClientNameConfigured_ShouldNormalizeTo } [Fact] - public async Task ConfigurationAIProviderConnectionCatalog_GetAllAsync_ShouldMergeStoredAndConfiguredConnections() + public async Task ConfigurationAIProviderConnectionStore_GetAllAsync_ShouldMergeStoredAndConfiguredConnections() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -109,8 +109,9 @@ public async Task ConfigurationAIProviderConnectionCatalog_GetAllAsync_ShouldMer }) .Build(); - var catalog = new ConfigurationAIProviderConnectionCatalog( - new TestAIProviderConnectionStore( + var store = CreateConnectionStore( + configuration, + dbEntries: [ new AIProviderConnection { @@ -123,19 +124,16 @@ public async Task ConfigurationAIProviderConnectionCatalog_GetAllAsync_ShouldMer ["ApiKey"] = "ui-secret", }, }, - ]), - configuration, - Options.Create(new AIProviderConnectionCatalogOptions()), - NullLogger.Instance); + ]); - var connections = await catalog.GetAllAsync(); + var connections = await store.GetAllAsync(); Assert.Contains(connections, connection => connection.Name == "config-primary" && AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)); Assert.Contains(connections, connection => connection.Name == "ui-secondary" && connection.ItemId == "ui-connection"); } [Fact] - public async Task ConfigurationAIProviderConnectionCatalog_GetAllAsync_ShouldReadEveryConfiguredConnectionSection() + public async Task ConfigurationAIProviderConnectionStore_GetAllAsync_ShouldReadEveryConfiguredConnectionSection() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -159,13 +157,9 @@ public async Task ConfigurationAIProviderConnectionCatalog_GetAllAsync_ShouldRea catalogOptions.ProviderSections.Add("Primary:Providers"); catalogOptions.ProviderSections.Add("Secondary:Providers"); - var catalog = new ConfigurationAIProviderConnectionCatalog( - new TestAIProviderConnectionStore([]), - configuration, - Options.Create(catalogOptions), - NullLogger.Instance); + var store = CreateConnectionStore(configuration, catalogOptions: catalogOptions); - var connections = await catalog.GetAllAsync(); + var connections = await store.GetAllAsync(); Assert.Contains(connections, connection => connection.Name == "config-primary" && connection.ClientName == "OpenAI"); Assert.Contains(connections, connection => connection.Name == "config-secondary" && connection.ClientName == "OpenAI"); @@ -356,7 +350,7 @@ public void AddCrestAppsAI_WhenCustomConnectionSectionsConfigured_ShouldMergeEve } [Fact] - public async Task ConfigurationAIProviderConnectionCatalog_ShouldSkipConfiguredConflictsByName() + public async Task ConfigurationAIProviderConnectionStore_ShouldSkipConfiguredConflictsByName() { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -366,8 +360,9 @@ public async Task ConfigurationAIProviderConnectionCatalog_ShouldSkipConfiguredC }) .Build(); - var catalog = new ConfigurationAIProviderConnectionCatalog( - new TestAIProviderConnectionStore( + var store = CreateConnectionStore( + configuration, + dbEntries: [ new AIProviderConnection { @@ -375,65 +370,53 @@ public async Task ConfigurationAIProviderConnectionCatalog_ShouldSkipConfiguredC ClientName = "OpenAI", Name = "shared-name", }, - ]), - configuration, - Options.Create(new AIProviderConnectionCatalogOptions()), - NullLogger.Instance); + ]); - var connections = await catalog.GetAllAsync(); + var connections = await store.GetAllAsync(); Assert.Single(connections); Assert.Equal("ui-connection", connections.Single().ItemId); } - private sealed class TestAIProviderConnectionStore(List connections) : INamedSourceCatalog + private static DefaultAIProviderConnectionStore CreateConnectionStore( + IConfiguration configuration, + AIProviderConnectionCatalogOptions catalogOptions = null, + List dbEntries = null) { - public ValueTask CreateAsync(AIProviderConnection entry) - { - connections.Add(entry); - return ValueTask.CompletedTask; - } - - public ValueTask DeleteAsync(AIProviderConnection entry) - { - connections.Remove(entry); - return ValueTask.FromResult(true); - } + var sources = new List>(); - public ValueTask FindByIdAsync(string id) + if (dbEntries is { Count: > 0 }) { - return ValueTask.FromResult(connections.FirstOrDefault(connection => connection.ItemId == id)); + sources.Add(new TestAIProviderConnectionSource(dbEntries)); } - public ValueTask FindByNameAsync(string name) - { - return ValueTask.FromResult(connections.FirstOrDefault(connection => connection.Name == name)); - } + sources.Add(new ConfigurationAIProviderConnectionSource( + configuration, + Options.Create(catalogOptions ?? new AIProviderConnectionCatalogOptions()), + NullLogger.Instance)); - public ValueTask> GetAllAsync() - { - return ValueTask.FromResult>(connections.ToArray()); - } + return new DefaultAIProviderConnectionStore(sources); + } - public ValueTask> GetAsync(IEnumerable ids) - { - return ValueTask.FromResult>(connections.Where(connection => ids.Contains(connection.ItemId)).ToArray()); - } + private sealed class TestAIProviderConnectionSource(List connections) : IWritableNamedSourceCatalogSource + { + public int Order => 0; - public ValueTask> GetAsync(string source) + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) { - return ValueTask.FromResult>(connections.Where(connection => connection.Source == source).ToArray()); + return ValueTask.FromResult>(connections.ToArray()); } - public ValueTask GetAsync(string name, string source) + public ValueTask CreateAsync(AIProviderConnection entry) { - return ValueTask.FromResult(connections.FirstOrDefault(connection => connection.Name == name && connection.Source == source)); + connections.Add(entry); + return ValueTask.CompletedTask; } - public ValueTask> PageAsync(int page, int pageSize, TQuery context) - where TQuery : QueryContext + public ValueTask DeleteAsync(AIProviderConnection entry) { - return ValueTask.FromResult(new PageResult { Count = connections.Count, Entries = connections.ToArray(), }); + connections.Remove(entry); + return ValueTask.FromResult(true); } public ValueTask UpdateAsync(AIProviderConnection entry) => ValueTask.CompletedTask; diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs index eb6ae58c..94ab56f4 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs @@ -1,7 +1,6 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; -using CrestApps.Core.Models; using CrestApps.Core.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; @@ -17,9 +16,11 @@ public async Task GetAllAsync_ShouldMergeStoredAndConfiguredStandaloneDeployment var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:0:ClientName"] = "AzureSpeech", ["CrestApps:AI:Deployments:0:Name"] = "whisper", ["CrestApps:AI:Deployments:0:Type"] = "SpeechToText", ["CrestApps:AI:Deployments:0:IsDefault"] = "true", ["CrestApps:AI:Deployments:0:Endpoint"] = "https://eastus.stt.speech.microsoft.com", ["CrestApps:AI:Deployments:0:AuthenticationType"] = "ApiKey", ["CrestApps:AI:Deployments:0:ApiKey"] = "secret", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var innerStore = new TestAIDeploymentStore([new AIDeployment { ItemId = "ui-deployment", Name = "ui-chat", ClientName = "OpenAI", Type = AIDeploymentType.Chat, },]); - var catalog = new ConfigurationAIDeploymentCatalog(innerStore, configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); - var deployments = await catalog.GetAllAsync(); + var store = CreateStore( + configuration, + aiOptions, + dbEntries: [new AIDeployment { ItemId = "ui-deployment", Name = "ui-chat", ClientName = "OpenAI", Type = AIDeploymentType.Chat, }]); + var deployments = await store.GetAllAsync(); Assert.Contains(deployments, deployment => deployment.ItemId == "ui-deployment"); var configuredDeployment = Assert.Single(deployments, deployment => deployment.Name == "whisper"); Assert.Equal("AzureSpeech", configuredDeployment.ClientName); @@ -36,8 +37,8 @@ public async Task FindByNameAsync_ShouldReturnConfiguredDeploymentWhenNotInStore var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:0:ClientName"] = "AzureSpeech", ["CrestApps:AI:Deployments:0:Name"] = "AzureTextToSpeech", ["CrestApps:AI:Deployments:0:Type"] = "TextToSpeech", ["CrestApps:AI:Deployments:0:IsDefault"] = "true", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); - var deployment = await catalog.FindByNameAsync("AzureTextToSpeech"); + var store = CreateStore(configuration, aiOptions); + var deployment = await store.FindByNameAsync("AzureTextToSpeech"); Assert.NotNull(deployment); Assert.Equal("AzureSpeech", deployment.ClientName); Assert.Equal(AIDeploymentType.TextToSpeech, deployment.Type); @@ -49,8 +50,8 @@ public async Task GetAllAsync_ShouldReadProviderGroupedStandaloneDeployments() var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:AzureSpeech:0:Name"] = "grouped-whisper", ["CrestApps:AI:Deployments:AzureSpeech:0:Type"] = "SpeechToText", ["CrestApps:AI:Deployments:AzureSpeech:0:IsDefault"] = "true", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); - var deployment = Assert.Single(await catalog.GetAllAsync()); + var store = CreateStore(configuration, aiOptions); + var deployment = Assert.Single(await store.GetAllAsync()); Assert.Equal("AzureSpeech", deployment.ClientName); Assert.Equal("grouped-whisper", deployment.Name); Assert.Equal(AIDeploymentType.SpeechToText, deployment.Type); @@ -62,8 +63,8 @@ public async Task GetAllAsync_ShouldPreserveConfiguredClientName() var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:0:ClientName"] = "AzureOpenAI", ["CrestApps:AI:Deployments:0:Name"] = "text-embedding-3-small", ["CrestApps:AI:Deployments:0:ModelName"] = "text-embedding-3-small", ["CrestApps:AI:Deployments:0:Type"] = "Embedding", ["CrestApps:AI:Deployments:0:Endpoint"] = "https://example.openai.azure.com/", ["CrestApps:AI:Deployments:0:AuthenticationType"] = "ApiKey", ["CrestApps:AI:Deployments:0:ApiKey"] = "secret", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureOpenAI"); - var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); - var deployment = Assert.Single(await catalog.GetAllAsync()); + var store = CreateStore(configuration, aiOptions); + var deployment = Assert.Single(await store.GetAllAsync()); Assert.Equal("AzureOpenAI", deployment.ClientName); Assert.Equal(AIDeploymentType.Embedding, deployment.Type); } @@ -83,14 +84,9 @@ public async Task GetAllAsync_ShouldLoadStandaloneDeploymentsForProvidersWithout var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("OpenAI"); - var catalog = new ConfigurationAIDeploymentCatalog( - new TestAIDeploymentStore([]), - configuration, - Options.Create(aiOptions), - Options.Create(new AIDeploymentCatalogOptions()), - NullLogger.Instance); + var store = CreateStore(configuration, aiOptions); - var deployment = Assert.Single(await catalog.GetAllAsync()); + var deployment = Assert.Single(await store.GetAllAsync()); Assert.Equal("OpenAI", deployment.ClientName); Assert.Equal("gpt-4.1", deployment.Name); @@ -124,14 +120,9 @@ public async Task GetAllAsync_ShouldReadEveryConfiguredDeploymentSectionAndPrese catalogOptions.DeploymentSections.Add("Primary:Deployments"); catalogOptions.DeploymentSections.Add("Secondary:Deployments"); - var catalog = new ConfigurationAIDeploymentCatalog( - new TestAIDeploymentStore([]), - configuration, - Options.Create(aiOptions), - Options.Create(catalogOptions), - NullLogger.Instance); + var store = CreateStore(configuration, aiOptions, catalogOptions: catalogOptions); - var deployments = await catalog.GetAllAsync(); + var deployments = await store.GetAllAsync(); var sharedDeployment = Assert.Single(deployments, x => x.Name == "gpt-4.1"); var containedDeployment = Assert.Single(deployments, x => x.Name == "speech-primary"); @@ -154,8 +145,10 @@ public async Task GetAllAsync_ShouldPreferStoredDeploymentWhenConfiguredNameConf var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var catalog = new ConfigurationAIDeploymentCatalog( - new TestAIDeploymentStore( + var store = CreateStore( + configuration, + aiOptions, + dbEntries: [ new AIDeployment { @@ -164,69 +157,57 @@ public async Task GetAllAsync_ShouldPreferStoredDeploymentWhenConfiguredNameConf ClientName = "OpenAI", Type = AIDeploymentType.Chat, }, - ]), - configuration, - Options.Create(aiOptions), - Options.Create(new AIDeploymentCatalogOptions()), - NullLogger.Instance); + ]); - var deployments = await catalog.GetAllAsync(); + var deployments = await store.GetAllAsync(); Assert.Single(deployments); Assert.Equal("ui-deployment", deployments.Single().ItemId); } - private sealed class TestAIDeploymentStore(List deployments) : INamedSourceCatalog + private static DefaultAIDeploymentStore CreateStore( + IConfiguration configuration, + AIOptions aiOptions, + AIDeploymentCatalogOptions catalogOptions = null, + List dbEntries = null) { - public ValueTask CreateAsync(AIDeployment entry) - { - deployments.Add(entry); - return ValueTask.CompletedTask; - } - - public ValueTask DeleteAsync(AIDeployment entry) - { - deployments.Remove(entry); - return ValueTask.FromResult(true); - } + var sources = new List>(); - public ValueTask FindByIdAsync(string id) + if (dbEntries is { Count: > 0 }) { - return ValueTask.FromResult(deployments.FirstOrDefault(deployment => deployment.ItemId == id)); + sources.Add(new TestAIDeploymentSource(dbEntries)); } - public ValueTask FindByNameAsync(string name) - { - return ValueTask.FromResult(deployments.FirstOrDefault(deployment => deployment.Name == name)); - } + sources.Add(new ConfigurationAIDeploymentSource( + configuration, + Options.Create(aiOptions), + Options.Create(catalogOptions ?? new AIDeploymentCatalogOptions()), + NullLogger.Instance)); - public ValueTask> GetAllAsync() - { - return ValueTask.FromResult>(deployments.ToArray()); - } + return new DefaultAIDeploymentStore(sources); + } - public ValueTask> GetAsync(IEnumerable ids) - { - return ValueTask.FromResult>(deployments.Where(deployment => ids.Contains(deployment.ItemId)).ToArray()); - } + private sealed class TestAIDeploymentSource(List deployments) : IWritableNamedSourceCatalogSource + { + public int Order => 0; - public ValueTask> GetAsync(string source) + public ValueTask> GetEntriesAsync(IReadOnlyCollection knownEntries) { - return ValueTask.FromResult>(deployments.Where(deployment => deployment.Source == source).ToArray()); + return ValueTask.FromResult>(deployments.ToArray()); } - public ValueTask GetAsync(string name, string source) + public ValueTask CreateAsync(AIDeployment entry) { - return ValueTask.FromResult(deployments.FirstOrDefault(deployment => deployment.Name == name && deployment.Source == source)); + deployments.Add(entry); + return ValueTask.CompletedTask; } - public ValueTask> PageAsync(int page, int pageSize, TQuery context) - where TQuery : QueryContext + public ValueTask DeleteAsync(AIDeployment entry) { - return ValueTask.FromResult(new PageResult { Count = deployments.Count, Entries = deployments.ToArray(), }); + deployments.Remove(entry); + return ValueTask.FromResult(true); } public ValueTask UpdateAsync(AIDeployment entry) => ValueTask.CompletedTask; } - } diff --git a/tests/CrestApps.Core.Tests/Mcp/DefaultOAuth2TokenServiceTests.cs b/tests/CrestApps.Core.Tests/Mcp/DefaultOAuth2TokenServiceTests.cs new file mode 100644 index 00000000..f5725af3 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Mcp/DefaultOAuth2TokenServiceTests.cs @@ -0,0 +1,407 @@ +using System.Net; +using System.Text.Json; +using CrestApps.Core.AI.Mcp.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CrestApps.Core.Tests.Mcp; + +public sealed class DefaultOAuth2TokenServiceTests +{ + private const string TokenEndpoint = "https://auth.example.com/oauth2/token"; + private const string ClientId = "test-client-id"; + private const string ClientSecret = "test-client-secret"; + + [Fact] + public async Task AcquireTokenAsync_SuccessfulResponse_ReturnsAccessToken() + { + // Arrange + var expectedToken = "access-token-abc123"; + var handler = CreateHandler(new TokenResponse(expectedToken, "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act + var token = await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, cancellationToken: ct); + + // Assert + Assert.Equal(expectedToken, token); + } + + [Fact] + public async Task AcquireTokenAsync_SendsCorrectParameters() + { + // Arrange + var scopes = "read write"; + Dictionary capturedForm = null; + var ct = TestContext.Current.CancellationToken; + + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600), request => + { + capturedForm = ParseFormContent(request); + }); + var service = CreateService(handler); + + // Act + await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, scopes, ct); + + // Assert + Assert.NotNull(capturedForm); + Assert.Equal("client_credentials", capturedForm["grant_type"]); + Assert.Equal(ClientId, capturedForm["client_id"]); + Assert.Equal(ClientSecret, capturedForm["client_secret"]); + Assert.Equal(scopes, capturedForm["scope"]); + } + + [Fact] + public async Task AcquireTokenAsync_NoScopes_DoesNotSendScopeParameter() + { + // Arrange + Dictionary capturedForm = null; + var ct = TestContext.Current.CancellationToken; + + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600), request => + { + capturedForm = ParseFormContent(request); + }); + var service = CreateService(handler); + + // Act + await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, cancellationToken: ct); + + // Assert + Assert.NotNull(capturedForm); + Assert.False(capturedForm.ContainsKey("scope")); + } + + [Fact] + public async Task AcquireTokenAsync_CachesToken_ReturnsCachedOnSecondCall() + { + // Arrange + var callCount = 0; + var handler = CreateHandler(new TokenResponse("cached-token", "Bearer", 3600), _ => callCount++); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act + var token1 = await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, "scope1", ct); + var token2 = await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, "scope1", ct); + + // Assert + Assert.Equal("cached-token", token1); + Assert.Equal("cached-token", token2); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AcquireTokenAsync_DifferentScopes_NotCachedTogether() + { + // Arrange + var callCount = 0; + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600), _ => callCount++); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act + await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, "scope-a", ct); + await service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, "scope-b", ct); + + // Assert + Assert.Equal(2, callCount); + } + + [Fact] + public async Task AcquireTokenAsync_ServerReturnsError_ThrowsHttpRequestException() + { + // Arrange + var handler = new MockHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("{\"error\":\"invalid_client\"}"), + })); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenAsync_EmptyAccessToken_ThrowsInvalidOperationException() + { + // Arrange + var handler = CreateHandler(new TokenResponse("", "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenAsync(TokenEndpoint, ClientId, ClientSecret, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenAsync_ThrowsOnNullTokenEndpoint() + { + // Arrange + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenAsync(null, ClientId, ClientSecret, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenAsync_ThrowsOnNullClientId() + { + // Arrange + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenAsync(TokenEndpoint, null, ClientSecret, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenAsync_ThrowsOnNullClientSecret() + { + // Arrange + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenAsync(TokenEndpoint, ClientId, null, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenWithPrivateKeyJwtAsync_SendsJwtClientAssertion() + { + // Arrange + Dictionary capturedForm = null; + var privateKeyPem = GenerateTestRsaPrivateKeyPem(); + var ct = TestContext.Current.CancellationToken; + + var handler = CreateHandler(new TokenResponse("pkjwt-token", "Bearer", 3600), request => + { + capturedForm = ParseFormContent(request); + }); + var service = CreateService(handler); + + // Act + var token = await service.AcquireTokenWithPrivateKeyJwtAsync( + TokenEndpoint, ClientId, privateKeyPem, "key-001", "api", ct); + + // Assert + Assert.Equal("pkjwt-token", token); + Assert.NotNull(capturedForm); + Assert.Equal("client_credentials", capturedForm["grant_type"]); + Assert.Equal(ClientId, capturedForm["client_id"]); + Assert.Equal("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", capturedForm["client_assertion_type"]); + Assert.True(capturedForm.ContainsKey("client_assertion")); + + // Verify the JWT has 3 parts (header.payload.signature). + var jwtParts = capturedForm["client_assertion"].Split('.'); + Assert.Equal(3, jwtParts.Length); + } + + [Fact] + public async Task AcquireTokenWithPrivateKeyJwtAsync_JwtContainsCorrectClaims() + { + // Arrange + Dictionary capturedForm = null; + var privateKeyPem = GenerateTestRsaPrivateKeyPem(); + var ct = TestContext.Current.CancellationToken; + + var handler = CreateHandler(new TokenResponse("pkjwt-token", "Bearer", 3600), request => + { + capturedForm = ParseFormContent(request); + }); + var service = CreateService(handler); + + // Act + await service.AcquireTokenWithPrivateKeyJwtAsync( + TokenEndpoint, ClientId, privateKeyPem, "key-001", "api", ct); + + // Assert + var jwt = capturedForm["client_assertion"]; + var parts = jwt.Split('.'); + + // Decode header. + var headerJson = Base64UrlDecode(parts[0]); + var header = JsonSerializer.Deserialize>(headerJson); + Assert.Equal("RS256", header["alg"]); + Assert.Equal("JWT", header["typ"]); + Assert.Equal("key-001", header["kid"]); + + // Decode payload. + var payloadJson = Base64UrlDecode(parts[1]); + var payload = JsonSerializer.Deserialize>(payloadJson); + Assert.Equal(ClientId, payload["iss"].ToString()); + Assert.Equal(ClientId, payload["sub"].ToString()); + Assert.Equal(TokenEndpoint, payload["aud"].ToString()); + Assert.True(payload.ContainsKey("jti")); + Assert.True(payload.ContainsKey("iat")); + Assert.True(payload.ContainsKey("exp")); + } + + [Fact] + public async Task AcquireTokenWithPrivateKeyJwtAsync_ThrowsOnNullPrivateKey() + { + // Arrange + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenWithPrivateKeyJwtAsync(TokenEndpoint, ClientId, null, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenWithMtlsAsync_ThrowsOnNullCertBytes() + { + // Arrange + var handler = CreateHandler(new TokenResponse("token", "Bearer", 3600)); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act & Assert + await Assert.ThrowsAsync( + () => service.AcquireTokenWithMtlsAsync(TokenEndpoint, ClientId, null, cancellationToken: ct)); + } + + [Fact] + public async Task AcquireTokenWithPrivateKeyJwtAsync_CachesToken() + { + // Arrange + var callCount = 0; + var privateKeyPem = GenerateTestRsaPrivateKeyPem(); + var handler = CreateHandler(new TokenResponse("pkjwt-cached", "Bearer", 3600), _ => callCount++); + var service = CreateService(handler); + var ct = TestContext.Current.CancellationToken; + + // Act + var token1 = await service.AcquireTokenWithPrivateKeyJwtAsync( + TokenEndpoint, ClientId, privateKeyPem, "key-001", "scope", ct); + var token2 = await service.AcquireTokenWithPrivateKeyJwtAsync( + TokenEndpoint, ClientId, privateKeyPem, "key-001", "scope", ct); + + // Assert + Assert.Equal("pkjwt-cached", token1); + Assert.Equal("pkjwt-cached", token2); + Assert.Equal(1, callCount); + } + + private static DefaultOAuth2TokenService CreateService(MockHttpMessageHandler handler) + { + var factory = new MockHttpClientFactory(handler); + var cache = new MemoryCache(new MemoryCacheOptions()); + var timeProvider = TimeProvider.System; + var logger = NullLogger.Instance; + + return new DefaultOAuth2TokenService(factory, cache, timeProvider, logger); + } + + private static MockHttpMessageHandler CreateHandler(TokenResponse tokenResponse, Action onRequest = null) + { + var json = JsonSerializer.Serialize(tokenResponse); + + return new MockHttpMessageHandler((request, _) => + { + onRequest?.Invoke(request); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }); + }); + } + /// + /// Synchronously parses form-encoded content from the request. + /// + private static Dictionary ParseFormContent(HttpRequestMessage request) + { + var content = request.Content as FormUrlEncodedContent; + var bytes = content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + var body = System.Text.Encoding.UTF8.GetString(bytes); + + return body.Split('&') + .Select(p => p.Split('=')) + .ToDictionary( + p => Uri.UnescapeDataString(p[0]), + p => Uri.UnescapeDataString(p[1].Replace('+', ' '))); + } + + private static string GenerateTestRsaPrivateKeyPem() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + return rsa.ExportRSAPrivateKeyPem(); + } + + private static string Base64UrlDecode(string input) + { + var padded = input + .Replace('-', '+') + .Replace('_', '/'); + + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + var bytes = Convert.FromBase64String(padded); + + return System.Text.Encoding.UTF8.GetString(bytes); + } + + private sealed class TokenResponse + { + public TokenResponse(string accessToken, string tokenType, int expiresIn) + { + AccessToken = accessToken; + TokenType = tokenType; + ExpiresIn = expiresIn; + } + + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string AccessToken { get; } + + [System.Text.Json.Serialization.JsonPropertyName("token_type")] + public string TokenType { get; } + + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int ExpiresIn { get; } + } + + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public MockHttpMessageHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => _handler(request, cancellationToken); + } + + private sealed class MockHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public MockHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) => new(_handler); + } +} diff --git a/tests/CrestApps.Core.Tests/Mcp/McpConnectionDeploymentSanitizationTests.cs b/tests/CrestApps.Core.Tests/Mcp/McpConnectionDeploymentSanitizationTests.cs new file mode 100644 index 00000000..524cd733 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Mcp/McpConnectionDeploymentSanitizationTests.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.Core; +using CrestApps.Core.AI.Mcp; +using CrestApps.Core.AI.Mcp.Models; + +namespace CrestApps.OrchardCore.Tests.Mcp; + +/// +/// Tests that deployment export never leaks sensitive credential data. +/// Validates the sanitization logic used by McpConnectionDeploymentSource. +/// +public sealed class McpConnectionDeploymentSanitizationTests +{ + private static readonly string[] _sensitiveFields = + [ + nameof(SseMcpConnectionMetadata.ApiKey), + nameof(SseMcpConnectionMetadata.BasicPassword), + nameof(SseMcpConnectionMetadata.OAuth2ClientSecret), + nameof(SseMcpConnectionMetadata.OAuth2PrivateKey), + nameof(SseMcpConnectionMetadata.OAuth2ClientCertificate), + nameof(SseMcpConnectionMetadata.OAuth2ClientCertificatePassword), + ]; + + [Fact] + public void SanitizeSensitiveData_ApiKey_ClearsApiKey() + { + // Arrange + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.ApiKey, + ApiKeyHeaderName = "Authorization", + ApiKeyPrefix = "Bearer", + ApiKey = "encrypted-api-key-secret", + }); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert + var metadata = GetMetadataNode(properties); + Assert.Equal(string.Empty, metadata[nameof(SseMcpConnectionMetadata.ApiKey)]?.GetValue()); + Assert.Equal("Authorization", metadata[nameof(SseMcpConnectionMetadata.ApiKeyHeaderName)]?.GetValue()); + Assert.Equal("Bearer", metadata[nameof(SseMcpConnectionMetadata.ApiKeyPrefix)]?.GetValue()); + } + + [Fact] + public void SanitizeSensitiveData_Basic_ClearsPassword() + { + // Arrange + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.Basic, + BasicUsername = "user", + BasicPassword = "encrypted-password-secret", + }); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert + var metadata = GetMetadataNode(properties); + Assert.Equal(string.Empty, metadata[nameof(SseMcpConnectionMetadata.BasicPassword)]?.GetValue()); + Assert.Equal("user", metadata[nameof(SseMcpConnectionMetadata.BasicUsername)]?.GetValue()); + } + + [Fact] + public void SanitizeSensitiveData_OAuth2ClientCredentials_ClearsClientSecret() + { + // Arrange + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.OAuth2ClientCredentials, + OAuth2TokenEndpoint = "https://auth.example.com/token", + OAuth2ClientId = "client-123", + OAuth2ClientSecret = "encrypted-client-secret", + OAuth2Scopes = "read write", + }); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert + var metadata = GetMetadataNode(properties); + Assert.Equal(string.Empty, metadata[nameof(SseMcpConnectionMetadata.OAuth2ClientSecret)]?.GetValue()); + Assert.Equal("https://auth.example.com/token", metadata[nameof(SseMcpConnectionMetadata.OAuth2TokenEndpoint)]?.GetValue()); + Assert.Equal("client-123", metadata[nameof(SseMcpConnectionMetadata.OAuth2ClientId)]?.GetValue()); + } + + [Fact] + public void SanitizeSensitiveData_OAuth2PrivateKeyJwt_ClearsPrivateKey() + { + // Arrange + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.OAuth2PrivateKeyJwt, + OAuth2TokenEndpoint = "https://auth.example.com/token", + OAuth2ClientId = "client-456", + OAuth2PrivateKey = "encrypted-private-key-pem", + OAuth2KeyId = "key-001", + OAuth2Scopes = "api", + }); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert + var metadata = GetMetadataNode(properties); + Assert.Equal(string.Empty, metadata[nameof(SseMcpConnectionMetadata.OAuth2PrivateKey)]?.GetValue()); + Assert.Equal("key-001", metadata[nameof(SseMcpConnectionMetadata.OAuth2KeyId)]?.GetValue()); + } + + [Fact] + public void SanitizeSensitiveData_OAuth2Mtls_ClearsCertificateAndPassword() + { + // Arrange + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.OAuth2Mtls, + OAuth2TokenEndpoint = "https://auth.example.com/token", + OAuth2ClientId = "client-789", + OAuth2ClientCertificate = "encrypted-cert-data", + OAuth2ClientCertificatePassword = "encrypted-cert-password", + OAuth2Scopes = "admin", + }); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert + var metadata = GetMetadataNode(properties); + Assert.Equal(string.Empty, metadata[nameof(SseMcpConnectionMetadata.OAuth2ClientCertificate)]?.GetValue()); + Assert.Equal(string.Empty, metadata[nameof(SseMcpConnectionMetadata.OAuth2ClientCertificatePassword)]?.GetValue()); + Assert.Equal("client-789", metadata[nameof(SseMcpConnectionMetadata.OAuth2ClientId)]?.GetValue()); + } + + [Fact] + public void SanitizeSensitiveData_AllSensitiveFieldsPopulated_AllCleared() + { + // Arrange — worst case: all sensitive fields have values. + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.OAuth2ClientCredentials, + ApiKey = "secret-api-key", + BasicPassword = "secret-password", + OAuth2ClientSecret = "secret-client-secret", + OAuth2PrivateKey = "secret-private-key", + OAuth2ClientCertificate = "secret-cert", + OAuth2ClientCertificatePassword = "secret-cert-pass", + }); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert — every sensitive field must be empty. + var metadata = GetMetadataNode(properties); + + foreach (var field in _sensitiveFields) + { + Assert.Equal(string.Empty, metadata[field]?.GetValue()); + } + } + + [Fact] + public void SanitizeSensitiveData_NonSseConnection_NoSanitization() + { + // Arrange — Stdio connection should not be sanitized. + var connection = new McpConnection + { + Source = McpConstants.TransportTypes.StdIo, + }; + + var customData = new JsonObject { ["Command"] = "docker" }; + + connection.Properties["StdioMcpConnectionMetadata"] = JsonSerializer.SerializeToNode(customData); + + // Act + var properties = ExportAndSanitize(connection); + + // Assert — properties should remain unchanged. + Assert.Equal("docker", properties["StdioMcpConnectionMetadata"]?["Command"]?.GetValue()); + } + + [Fact] + public void SanitizeSensitiveData_ExportedJson_NeverContainsSensitiveValues() + { + // Arrange + var secretValues = new[] + { + "super-secret-api-key-12345", + "super-secret-password-67890", + "super-secret-client-secret-abcde", + "super-secret-private-key-fghij", + "super-secret-certificate-klmno", + "super-secret-cert-password-pqrst", + }; + + var connection = CreateSseConnectionWithMetadata(new SseMcpConnectionMetadata + { + Endpoint = new Uri("https://mcp.example.com/sse"), + AuthenticationType = McpClientAuthenticationType.OAuth2Mtls, + ApiKey = secretValues[0], + BasicPassword = secretValues[1], + OAuth2ClientSecret = secretValues[2], + OAuth2PrivateKey = secretValues[3], + OAuth2ClientCertificate = secretValues[4], + OAuth2ClientCertificatePassword = secretValues[5], + }); + + // Act + var properties = ExportAndSanitize(connection); + var serialized = properties.ToJsonString(); + + // Assert — the serialized JSON must not contain any of the secret values. + + foreach (var secret in secretValues) + { + Assert.DoesNotContain(secret, serialized); + } + } + /// + /// Simulates the export sanitization done by McpConnectionDeploymentSource. + /// + private static JsonObject ExportAndSanitize(McpConnection connection) + { + var properties = new JsonObject(); + + foreach (var property in connection.Properties) + { + // Convert to JsonNode and deep clone + var json = JsonSerializer.Serialize(property.Value); + var node = JsonNode.Parse(json); + properties[property.Key] = node; + } + + // Apply the same sanitization logic used in McpConnectionDeploymentSource. + + if (string.Equals(connection.Source, McpConstants.TransportTypes.Sse, StringComparison.Ordinal)) + { + var metadataNode = properties[nameof(SseMcpConnectionMetadata)]?.AsObject(); + + if (metadataNode != null) + { + foreach (var field in _sensitiveFields) + { + metadataNode[field] = string.Empty; + } + } + } + + return properties; + } + + private static JsonObject GetMetadataNode(JsonObject properties) + => properties[nameof(SseMcpConnectionMetadata)]?.AsObject() + ?? throw new InvalidOperationException("SseMcpConnectionMetadata not found in properties."); + + private static McpConnection CreateSseConnectionWithMetadata(SseMcpConnectionMetadata metadata) + { + var connection = new McpConnection + { + Source = McpConstants.TransportTypes.Sse, + }; + + connection.Put(metadata); + + return connection; + } +} diff --git a/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs b/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs new file mode 100644 index 00000000..c47192f3 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs @@ -0,0 +1,371 @@ +using System.Text; +using CrestApps.Core; +using CrestApps.Core.AI.Mcp; +using CrestApps.Core.AI.Mcp.Models; +using CrestApps.Core.AI.Mcp.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Client; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Mcp; + +public sealed class SseClientTransportProviderTests +{ + private const string TestEndpoint = "https://mcp.example.com/sse"; + + [Fact] + public void CanHandle_SseConnection_ReturnsTrue() + { + // Arrange + var connection = new McpConnection { Source = McpConstants.TransportTypes.Sse }; + + var provider = CreateProvider(); + + // Act & Assert + Assert.True(provider.CanHandle(connection)); + } + + [Fact] + public void CanHandle_NonSseConnection_ReturnsFalse() + { + // Arrange + var connection = new McpConnection { Source = McpConstants.TransportTypes.StdIo }; + + var provider = CreateProvider(); + + // Act & Assert + Assert.False(provider.CanHandle(connection)); + } + + [Fact] + public async Task GetAsync_Anonymous_ReturnsTransportWithNoAuthHeaders() + { + // Arrange + var connection = CreateConnection(McpClientAuthenticationType.Anonymous); + var provider = CreateProvider(); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.Empty(headers); + } + + [Theory] + [InlineData("Authorization", "Bearer", "my-api-key", "Authorization", "Bearer my-api-key")] + [InlineData("X-Api-Key", "", "my-api-key", "X-Api-Key", "my-api-key")] + [InlineData("X-Api-Key", null, "my-api-key", "X-Api-Key", "my-api-key")] + [InlineData("", "Bearer", "my-api-key", "Authorization", "Bearer my-api-key")] + [InlineData(null, "Bearer", "my-api-key", "Authorization", "Bearer my-api-key")] + [InlineData(null, null, "my-api-key", "Authorization", "my-api-key")] + public async Task GetAsync_ApiKey_SetsCorrectHeader( + string headerName, + string prefix, + string apiKey, + string expectedHeaderName, + string expectedHeaderValue) + { + // Arrange + var connection = CreateConnection(McpClientAuthenticationType.ApiKey); + var metadata = connection.As(); + metadata.ApiKeyHeaderName = headerName; + metadata.ApiKeyPrefix = prefix; + metadata.ApiKey = apiKey; + connection.Put(metadata); + + var provider = CreateProvider(); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.True(headers.ContainsKey(expectedHeaderName)); + Assert.Equal(expectedHeaderValue, headers[expectedHeaderName]); + } + + [Fact] + public async Task GetAsync_Basic_SetsBase64AuthorizationHeader() + { + // Arrange + var username = "testuser"; + var password = "testpass"; + var connection = CreateConnection(McpClientAuthenticationType.Basic); + var metadata = connection.As(); + metadata.BasicUsername = username; + metadata.BasicPassword = password; + connection.Put(metadata); + + var provider = CreateProvider(); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + var expectedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + Assert.True(headers.ContainsKey("Authorization")); + Assert.Equal($"Basic {expectedCredentials}", headers["Authorization"]); + } + + [Fact] + public async Task GetAsync_Basic_WithEmptyPassword_SetsHeaderWithEmptyPassword() + { + // Arrange + var connection = CreateConnection(McpClientAuthenticationType.Basic); + var metadata = connection.As(); + metadata.BasicUsername = "testuser"; + metadata.BasicPassword = null; + connection.Put(metadata); + + var provider = CreateProvider(); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + var expectedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes("testuser:")); + Assert.True(headers.ContainsKey("Authorization")); + Assert.Equal($"Basic {expectedCredentials}", headers["Authorization"]); + } + + [Fact] + public async Task GetAsync_OAuth2ClientCredentials_AcquiresTokenAndSetsBearerHeader() + { + // Arrange + var expectedToken = "oauth2-access-token-123"; + var tokenEndpoint = "https://auth.example.com/token"; + var clientId = "my-client-id"; + var clientSecret = "my-client-secret"; + var scopes = "read write"; + + var connection = CreateConnection(McpClientAuthenticationType.OAuth2ClientCredentials); + var metadata = connection.As(); + metadata.OAuth2TokenEndpoint = tokenEndpoint; + metadata.OAuth2ClientId = clientId; + metadata.OAuth2ClientSecret = clientSecret; + metadata.OAuth2Scopes = scopes; + connection.Put(metadata); + + var tokenService = new Mock(); + tokenService + .Setup(x => x.AcquireTokenAsync(tokenEndpoint, clientId, clientSecret, scopes, It.IsAny())) + .ReturnsAsync(expectedToken); + + var provider = CreateProvider(tokenService: tokenService.Object); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.True(headers.ContainsKey("Authorization")); + Assert.Equal($"Bearer {expectedToken}", headers["Authorization"]); + + tokenService.Verify(x => x.AcquireTokenAsync(tokenEndpoint, clientId, clientSecret, scopes, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetAsync_OAuth2PrivateKeyJwt_AcquiresTokenAndSetsBearerHeader() + { + // Arrange + var expectedToken = "pkjwt-access-token-456"; + var tokenEndpoint = "https://auth.example.com/token"; + var clientId = "my-client-id"; + var privateKey = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----"; + var keyId = "key-001"; + var scopes = "api"; + + var connection = CreateConnection(McpClientAuthenticationType.OAuth2PrivateKeyJwt); + var metadata = connection.As(); + metadata.OAuth2TokenEndpoint = tokenEndpoint; + metadata.OAuth2ClientId = clientId; + metadata.OAuth2PrivateKey = privateKey; + metadata.OAuth2KeyId = keyId; + metadata.OAuth2Scopes = scopes; + connection.Put(metadata); + + var tokenService = new Mock(); + tokenService + .Setup(x => x.AcquireTokenWithPrivateKeyJwtAsync(tokenEndpoint, clientId, privateKey, keyId, scopes, It.IsAny())) + .ReturnsAsync(expectedToken); + + var provider = CreateProvider(tokenService: tokenService.Object); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.True(headers.ContainsKey("Authorization")); + Assert.Equal($"Bearer {expectedToken}", headers["Authorization"]); + + tokenService.Verify(x => x.AcquireTokenWithPrivateKeyJwtAsync(tokenEndpoint, clientId, privateKey, keyId, scopes, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetAsync_OAuth2Mtls_AcquiresTokenAndSetsBearerHeader() + { + // Arrange + var expectedToken = "mtls-access-token-789"; + var tokenEndpoint = "https://auth.example.com/token"; + var clientId = "my-client-id"; + var rawCertBytes = new byte[] { 1, 2, 3, 4, 5 }; + + var certBase64 = Convert.ToBase64String(rawCertBytes); + var certPassword = "cert-password"; + var scopes = "admin"; + + // Protect values like the display driver does before storing. + var protector = new PassthroughDataProtectionProvider().CreateProtector("test"); + + var connection = CreateConnection(McpClientAuthenticationType.OAuth2Mtls); + var metadata = connection.As(); + metadata.OAuth2TokenEndpoint = tokenEndpoint; + metadata.OAuth2ClientId = clientId; + metadata.OAuth2ClientCertificate = protector.Protect(certBase64); + metadata.OAuth2ClientCertificatePassword = protector.Protect(certPassword); + metadata.OAuth2Scopes = scopes; + connection.Put(metadata); + + var tokenService = new Mock(); + tokenService + .Setup(x => x.AcquireTokenWithMtlsAsync( + tokenEndpoint, + clientId, + It.Is(b => b.SequenceEqual(rawCertBytes)), + certPassword, + scopes, + It.IsAny())) + .ReturnsAsync(expectedToken); + + var provider = CreateProvider(tokenService: tokenService.Object); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.True(headers.ContainsKey("Authorization")); + Assert.Equal($"Bearer {expectedToken}", headers["Authorization"]); + } + + [Fact] + public async Task GetAsync_CustomHeaders_PassesAllHeaders() + { + // Arrange + var connection = CreateConnection(McpClientAuthenticationType.CustomHeaders); + var metadata = connection.As(); + metadata.AdditionalHeaders = new Dictionary + { + ["X-Custom-Header"] = "custom-value", + ["Authorization"] = "Bearer custom-token", + }; + + connection.Put(metadata); + + var provider = CreateProvider(); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.Equal(2, headers.Count); + Assert.Equal("custom-value", headers["X-Custom-Header"]); + + Assert.Equal("Bearer custom-token", headers["Authorization"]); + } + + [Fact] + public async Task GetAsync_CustomHeaders_WithNullHeaders_ReturnsEmptyHeaders() + { + // Arrange + var connection = CreateConnection(McpClientAuthenticationType.CustomHeaders); + var metadata = connection.As(); + metadata.AdditionalHeaders = null; + connection.Put(metadata); + + var provider = CreateProvider(); + + // Act + var headers = await GetHeadersAsync(provider, connection); + + // Assert + Assert.Empty(headers); + } + + [Fact] + public async Task GetAsync_OAuth2ClientCredentials_WhenTokenAcquisitionFails_ThrowsException() + { + // Arrange + var connection = CreateConnection(McpClientAuthenticationType.OAuth2ClientCredentials); + var metadata = connection.As(); + metadata.OAuth2TokenEndpoint = "https://auth.example.com/token"; + metadata.OAuth2ClientId = "client-id"; + metadata.OAuth2ClientSecret = "client-secret"; + connection.Put(metadata); + + var tokenService = new Mock(); + tokenService + .Setup(x => x.AcquireTokenAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new HttpRequestException("Token request failed")); + + var provider = CreateProvider(tokenService: tokenService.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => provider.GetAsync(connection)); + } + + private static McpConnection CreateConnection(McpClientAuthenticationType authType) + { + var connection = new McpConnection + { + Source = McpConstants.TransportTypes.Sse, + }; + + var metadata = new SseMcpConnectionMetadata + { + Endpoint = new Uri(TestEndpoint), + AuthenticationType = authType, + }; + + connection.Put(metadata); + + return connection; + } + + private static SseClientTransportProvider CreateProvider(IOAuth2TokenService tokenService = null) + { + var dataProtectionProvider = new PassthroughDataProtectionProvider(); + tokenService ??= Mock.Of(); + var logger = NullLogger.Instance; + + return new SseClientTransportProvider(dataProtectionProvider, tokenService, logger); + } + + private static async Task> GetHeadersAsync(SseClientTransportProvider provider, McpConnection connection) + { + var transport = await provider.GetAsync(connection); + + // Extract headers via reflection since HttpClientTransport doesn't expose them directly. + var optionsField = typeof(HttpClientTransport) + .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var options = optionsField?.GetValue(transport) as HttpClientTransportOptions; + + return options?.AdditionalHeaders as Dictionary + ?? new Dictionary(); + } + /// + /// A pass-through data protector that returns values unchanged. + /// This simulates the behavior of decryption returning the original value. + /// + private sealed class PassthroughDataProtectionProvider : IDataProtectionProvider + { + public IDataProtector CreateProtector(string purpose) => new PassthroughDataProtector(); + } + + private sealed class PassthroughDataProtector : IDataProtector + { + public IDataProtector CreateProtector(string purpose) => this; + + public byte[] Protect(byte[] plaintext) => plaintext; + + public byte[] Unprotect(byte[] protectedData) => protectedData; + } +} diff --git a/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryOrchestrationContextHelperTests.cs b/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryOrchestrationContextHelperTests.cs new file mode 100644 index 00000000..e40e21df --- /dev/null +++ b/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryOrchestrationContextHelperTests.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using CrestApps.Core.AI.Handlers; +using Microsoft.AspNetCore.Http; + +namespace CrestApps.OrchardCore.Tests.Modules.AI.Memory.Handlers; + +public sealed class AIMemoryOrchestrationContextHelperTests +{ + [Fact] + public void GetAuthenticatedUserId_WhenNameIdentifierExists_ShouldReturnIt() + { + var accessor = CreateAccessor( + new Claim(ClaimTypes.NameIdentifier, "user-id"), + new Claim(ClaimTypes.Name, "admin")); + + var userId = AIMemoryOrchestrationContextHelper.GetAuthenticatedUserId(accessor); + + Assert.Equal("user-id", userId); + } + + [Fact] + public void GetAuthenticatedUserId_WhenOnlyNameExists_ShouldFallbackToName() + { + var accessor = CreateAccessor(new Claim(ClaimTypes.Name, "admin")); + + var userId = AIMemoryOrchestrationContextHelper.GetAuthenticatedUserId(accessor); + + Assert.Equal("admin", userId); + } + + private static HttpContextAccessor CreateAccessor(params Claim[] claims) + { + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal( + new ClaimsIdentity(claims, "Cookies")); + + return new HttpContextAccessor + { + HttpContext = httpContext, + }; + } +} diff --git a/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs b/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs new file mode 100644 index 00000000..0905875c --- /dev/null +++ b/tests/CrestApps.Core.Tests/Modules/AI.Memory/Handlers/AIMemoryPreemptiveRagHandlerTests.cs @@ -0,0 +1,157 @@ +using System.Security.Claims; +using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Memory; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Templates.Models; +using CrestApps.Core.Templates.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +#pragma warning disable MEAI001 + +namespace CrestApps.OrchardCore.Tests.Modules.AI.Memory.Handlers; + +public sealed class AIMemoryPreemptiveRagHandlerTests +{ + [Fact] + public async Task CanHandleAsync_AuthenticatedProfileWithMemoryEnabled_ReturnsTrue() + { + var handler = CreateHandler(); + var profile = new AIProfile(); + profile.AlterMemoryMetadata(settings => settings.EnableUserMemory = true); + + var canHandle = await handler.CanHandleAsync(new OrchestrationContextBuiltContext(profile, new OrchestrationContext())); + + Assert.True(canHandle); + } + + [Fact] + public async Task CanHandleAsync_PreemptiveMemoryRetrievalDisabled_ReturnsFalse() + { + var handler = CreateHandler(enablePreemptiveMemoryRetrieval: false); + var profile = new AIProfile(); + profile.AlterMemoryMetadata(settings => settings.EnableUserMemory = true); + + var canHandle = await handler.CanHandleAsync(new OrchestrationContextBuiltContext(profile, new OrchestrationContext())); + + Assert.False(canHandle); + } + + [Fact] + public async Task HandleAsync_RelevantMemoriesFound_AppendsMemoryContext() + { + var handler = CreateHandler( + [ + new AIMemorySearchResult + { + MemoryId = "memory-1", + Name = "preferred_name", + Description = "The user's preferred name.", + Content = "Mike", + UpdatedUtc = new DateTime(2026, 3, 21, 0, 0, 0, DateTimeKind.Utc), + Score = 0.98f, + }, + ]); + + var context = new OrchestrationContext + { + DisableTools = false, + CompletionContext = new AICompletionContext(), + }; + + await handler.HandleAsync(new PreemptiveRagContext(context, new AIProfile(), ["What is my preferred name?"])); + + var systemMessage = context.SystemMessageBuilder.ToString(); + Assert.Contains("[Retrieved User Memory]", systemMessage); + Assert.Contains("search_user_memories", systemMessage); + Assert.Contains("Memory: preferred_name", systemMessage); + Assert.Contains("Description: The user's preferred name.", systemMessage); + Assert.Contains("Content: Mike", systemMessage); + } + + private static AIMemoryPreemptiveRagHandler CreateHandler( + IEnumerable results = null, + string userId = "user-1", + bool enableChatInteractionMemory = true, + bool enablePreemptiveMemoryRetrieval = true) + { + var memorySearchService = new Mock(); + memorySearchService + .Setup(service => service.SearchAsync( + "user-1", + It.IsAny>(), + null, + It.IsAny())) + .ReturnsAsync(results ?? []); + + var httpContextAccessor = new HttpContextAccessor + { + HttpContext = new DefaultHttpContext + { + User = string.IsNullOrEmpty(userId) + ? new ClaimsPrincipal(new ClaimsIdentity()) + : new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, userId), + ], "TestAuth")), + }, + }; + + return new AIMemoryPreemptiveRagHandler( + memorySearchService.Object, + new FakeAITemplateService(), + Options.Create(new GeneralAIOptions + { + EnablePreemptiveMemoryRetrieval = enablePreemptiveMemoryRetrieval, + }), + Options.Create(new ChatInteractionMemoryOptions + { + EnableUserMemory = enableChatInteractionMemory, + }), + httpContextAccessor, + NullLogger.Instance); + } + + private sealed class FakeAITemplateService : ITemplateService + { + public Task> ListAsync() + => Task.FromResult>([]); + + public Task