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