From 3d3737aa780eb47dad25c26f85dd91d129f59a39 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 30 Apr 2026 09:49:31 -0700 Subject: [PATCH 1/4] Improve Core Quality --- .github/workflows/e2e_samples.yml | 107 ++++++ Directory.Build.props | 26 +- Directory.Packages.props | 1 + .../IOrchestrationContextBuilder.cs | 3 +- .../IOrchestrationContextBuilderHandler.cs | 6 +- .../docs/changelog/v1.0.0.md | 1 + .../docs/providers/architecture.md | 350 ++++++++++++++++++ .../ExtractedDataOrchestrationHandler.cs | 8 +- .../Hubs/AIChatHubCore.cs | 5 +- .../Services/DataExtractionService.cs | 12 +- .../Services/PostSessionProcessingService.cs | 17 +- .../ClaudeOrchestrationContextHandler.cs | 6 +- .../CopilotOrchestrationContextHandler.cs | 6 +- .../Services/GitHubOAuthService.cs | 117 +++++- .../Handlers/DocumentOrchestrationHandler.cs | 8 +- .../IO/LimitedWriteStream.cs | 111 ++++++ .../IO/ResourceSizeLimitExceededException.cs | 19 + .../RemoteFileResourceHandlerBase.cs | 172 +++++++++ .../Handlers/AIMemoryOrchestrationHandler.cs | 8 +- ...oolExecutionContextOrchestrationHandler.cs | 6 +- ...AgentOrchestrationContextBuilderHandler.cs | 10 +- .../CompletionContextOrchestrationHandler.cs | 8 +- .../DataSourceOrchestrationHandler.cs | 8 +- .../PreemptiveRagOrchestrationHandler.cs | 22 +- .../EmbeddingSearchIndexProfileHandlerBase.cs | 11 +- .../Orchestration/DefaultOrchestrator.cs | 2 +- .../ServiceCollectionExtensions.cs | 7 + .../Services/AIChatResponseHandler.cs | 28 +- .../Services/BoundedClientCache.cs | 138 +++++++ .../DefaultOrchestrationContextBuilder.cs | 7 +- .../Tools/GenerateChartTool.cs | 2 +- .../Services/ElasticsearchClientFactory.cs | 2 +- .../DictionaryExtensions.cs | 6 +- .../RedactedSecret.cs | 47 +++ .../Extensions/ServiceCollectionExtensions.cs | 8 + .../EmbeddedResourceTemplateProvider.cs | 14 +- .../Providers/FileSystemTemplateProvider.cs | 10 +- .../Providers/ITemplateProvider.cs | 3 +- .../Providers/OptionsTemplateProvider.cs | 8 +- .../PromptsFileSystemTemplateProvider.cs | 10 +- .../Rendering/FluidTemplateEngine.cs | 46 ++- .../Rendering/ITemplateEngine.cs | 3 +- .../Services/DefaultTemplateService.cs | 70 ++-- .../Services/ITemplateService.cs | 12 +- .../Services/TemplateServiceExtensions.cs | 5 +- .../Tags/IncludeTemplateTag.cs | 168 ++++++++- .../Tags/RenderTemplateTag.cs | 25 +- .../TemplateBuilder.cs | 54 ++- .../Filters/NoOpStoreCommitter.cs | 18 + .../Pages/AI/AIProfiles/Create.razor | 6 +- .../Components/Pages/AI/AIProfiles/Edit.razor | 6 +- .../Pages/AI/Templates/Create.razor | 5 +- .../Components/Pages/AI/Templates/Edit.razor | 5 +- .../Pages/Indexing/IndexProfiles/Create.razor | 11 +- .../Pages/Indexing/IndexProfiles/Edit.razor | 11 +- .../AI/Controllers/AIProfileController.cs | 63 +--- .../AI/Controllers/AITemplateController.cs | 62 +--- .../Controllers/CopilotAuthController.cs | 24 +- .../Controllers/ChatInteractionController.cs | 30 +- .../Controllers/IndexProfileController.cs | 12 +- .../AIProfileSystemPromptTemplateProvider.cs | 4 +- .../CrestAppsEntityDbContext.cs | 33 +- .../EntityCoreDataStoreOptions.cs | 25 +- .../ICrestAppsModelConfigurer.cs | 24 ++ .../ServiceCollectionExtensions.cs | 17 +- .../Services/CrestAppsModelCacheKeyFactory.cs | 24 ++ .../Services/CrestAppsOptionsExtension.cs | 67 ++++ .../Prompting/AITemplateBuilderTests.cs | 16 +- .../Prompting/AITemplateProviderTests.cs | 38 +- .../DefaultAITemplateServiceTests.cs | 31 +- .../Prompting/FluidAITemplateEngineTests.cs | 21 +- .../Prompting/IncludeTemplateFilterTests.cs | 233 ++++++++++++ .../Prompting/RenderAITemplateTagTests.cs | 51 ++- .../Prompting/TemplateLiquidRenderingTests.cs | 87 ++++- .../Chat/DocumentPreemptiveRagHandlerTests.cs | 8 +- .../ExtractedDataOrchestrationHandlerTests.cs | 22 +- .../OpenXmlIngestionDocumentReaderTests.cs | 93 +++++ .../PdfIngestionDocumentReaderTests.cs | 82 ++++ .../PlainTextIngestionDocumentReaderTests.cs | 59 +++ ...EmbeddingSearchIndexProfileHandlerTests.cs | 218 +++++++++++ .../Mcp/RemoteFileResourceHandlerBaseTests.cs | 182 +++++++++ .../Core/Models/AIProfileExtensionsTests.cs | 83 ++--- .../DataSourceOrchestrationHandlerTests.cs | 12 +- ...DefaultOrchestrationContextBuilderTests.cs | 66 ++-- .../DefaultOrchestratorResolverTests.cs | 8 +- .../Orchestration/DefaultOrchestratorTests.cs | 8 +- .../DocumentOrchestrationHandlerTests.cs | 32 +- .../PreemptiveRagOrchestrationHandlerTests.cs | 30 +- .../Core/RedactedSecretTests.cs | 74 ++++ .../Core/Services/BoundedClientCacheTests.cs | 119 ++++++ .../Services/GitHubOAuthServiceStateTests.cs | 232 ++++++++++++ .../PostSessionProcessingServiceTests.cs | 24 +- .../Services/StoreCommitterFilterTests.cs | 159 ++++++++ .../EntityCoreStoreTests.cs | 67 +++- .../AI/DataExtractionServiceTests.cs | 8 +- .../Mcp/LimitedWriteStreamTests.cs | 146 ++++++++ .../AIMemoryPreemptiveRagHandlerTests.cs | 8 +- 97 files changed, 3823 insertions(+), 564 deletions(-) create mode 100644 .github/workflows/e2e_samples.yml create mode 100644 src/CrestApps.Core.Docs/docs/providers/architecture.md create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/IO/LimitedWriteStream.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/IO/ResourceSizeLimitExceededException.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Mcp/RemoteFileResourceHandlerBase.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Services/BoundedClientCache.cs create mode 100644 src/Primitives/CrestApps.Core.Infrastructure/RedactedSecret.cs create mode 100644 src/Primitives/CrestApps.Core/Filters/NoOpStoreCommitter.cs create mode 100644 src/Stores/CrestApps.Core.Data.EntityCore/ICrestAppsModelConfigurer.cs create mode 100644 src/Stores/CrestApps.Core.Data.EntityCore/Services/CrestAppsModelCacheKeyFactory.cs create mode 100644 src/Stores/CrestApps.Core.Data.EntityCore/Services/CrestAppsOptionsExtension.cs create mode 100644 tests/CrestApps.Core.Tests/AITemplates/Prompting/IncludeTemplateFilterTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Documents/Services/OpenXmlIngestionDocumentReaderTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Documents/Services/PdfIngestionDocumentReaderTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Documents/Services/PlainTextIngestionDocumentReaderTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Indexing/EmbeddingSearchIndexProfileHandlerTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Mcp/RemoteFileResourceHandlerBaseTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/RedactedSecretTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Services/BoundedClientCacheTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Services/GitHubOAuthServiceStateTests.cs create mode 100644 tests/CrestApps.Core.Tests/Core/Services/StoreCommitterFilterTests.cs create mode 100644 tests/CrestApps.Core.Tests/Mcp/LimitedWriteStreamTests.cs diff --git a/.github/workflows/e2e_samples.yml b/.github/workflows/e2e_samples.yml new file mode 100644 index 00000000..c880323f --- /dev/null +++ b/.github/workflows/e2e_samples.yml @@ -0,0 +1,107 @@ +name: E2E - Sample Hosts + +# Runs the Playwright-based CrestApps.Core.Tests.Samples suite against the MVC and Blazor sample hosts. +# This workflow is intentionally separate from the PR/main CI pipelines so e2e cost and flakiness do not +# block routine pull requests. It runs nightly and can also be triggered manually from the Actions tab. + +on: + workflow_dispatch: + schedule: + # 07:00 UTC daily (~03:00 ET / midnight PT) — chosen to land before the start of the workday. + - cron: '0 7 * * *' + +permissions: + contents: read + +concurrency: + group: e2e-samples-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + ASPNETCORE_ENVIRONMENT: Development + CRESTAPPS_MVC_BASE_URL: http://localhost:5101 + CRESTAPPS_BLAZOR_BASE_URL: http://localhost:5201 + +jobs: + e2e: + name: E2E - Playwright + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + + - name: Build solution + run: | + dotnet build ./CrestApps.Core.slnx -c Release /p:RunAnalyzers=true /p:NuGetAudit=false + + - name: Install Playwright browsers (Chromium) + run: | + pwsh ./tests/CrestApps.Core.Tests.Samples/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Start MVC sample host + run: | + mkdir -p ./artifacts/e2e-logs + nohup dotnet run --no-build -c Release \ + --project ./src/Startup/CrestApps.Core.Mvc.Web/CrestApps.Core.Mvc.Web.csproj \ + --urls "http://localhost:5101" \ + > ./artifacts/e2e-logs/mvc.log 2>&1 & + echo "MVC_PID=$!" >> "$GITHUB_ENV" + + - name: Start Blazor sample host + run: | + nohup dotnet run --no-build -c Release \ + --project ./src/Startup/CrestApps.Core.Blazor.Web/CrestApps.Core.Blazor.Web.csproj \ + --urls "http://localhost:5201" \ + > ./artifacts/e2e-logs/blazor.log 2>&1 & + echo "BLAZOR_PID=$!" >> "$GITHUB_ENV" + + - name: Wait for sample hosts to become reachable + run: | + set -euo pipefail + wait_for() { + local name="$1"; local url="$2"; local tries=60 + for i in $(seq 1 "$tries"); do + if curl -fsS --max-time 5 "$url" > /dev/null; then + echo "$name reachable after ${i}s ($url)" + return 0 + fi + sleep 2 + done + echo "::error::$name did not become reachable at $url within $((tries * 2))s" + return 1 + } + wait_for "MVC" "$CRESTAPPS_MVC_BASE_URL/Account/Login" + wait_for "Blazor" "$CRESTAPPS_BLAZOR_BASE_URL/account/login" + + - name: Run E2E sample tests + run: | + dotnet test ./tests/CrestApps.Core.Tests.Samples/CrestApps.Core.Tests.Samples.csproj \ + -c Release --no-build \ + --logger "trx;LogFileName=samples.trx" \ + --results-directory ./artifacts/e2e-results + + - name: Stop sample hosts + if: always() + run: | + for pid in "${MVC_PID:-}" "${BLAZOR_PID:-}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" || true + fi + done + + - name: Upload e2e logs and results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-samples-artifacts + path: | + ./artifacts/e2e-logs/** + ./artifacts/e2e-results/** + if-no-files-found: warn diff --git a/Directory.Build.props b/Directory.Build.props index a25d364f..80d877ae 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -46,7 +46,7 @@ 1.0.0 - preview + $(VersionSuffix)-$(BuildNumber) @@ -56,6 +56,22 @@ true + + true + true + + + + true + true + true + true + + + + + + latest-Recommended @@ -111,6 +127,14 @@ $(NoWarn);NU1605 + + $(NoWarn);NU5104 + $(NoWarn),1573,1591,1712 diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b08ed76..220ae4aa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilder.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilder.cs index 68eae99e..f6d7ffe8 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilder.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilder.cs @@ -24,8 +24,9 @@ public interface IOrchestrationContextBuilder /// An optional delegate to override or fine-tune the context after handlers have run /// /// BuildingAsync but before BuiltAsync. + /// The cancellation token for the build operation. /// /// A task that completes with the fully built . /// Thrown if is . - ValueTask BuildAsync(object resource, Action configure = null); + ValueTask BuildAsync(object resource, Action configure = null, CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilderHandler.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilderHandler.cs index ce03e508..0a450525 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilderHandler.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IOrchestrationContextBuilderHandler.cs @@ -19,14 +19,16 @@ public interface IOrchestrationContextBuilderHandler /// configuration delegate is applied. /// /// Carries both the source resource and the mutable . + /// The cancellation token for the build operation. /// A task that completes when the mutation or validation is done. - Task BuildingAsync(OrchestrationContextBuildingContext context); + Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default); /// /// Called after the context has been fully constructed and the optional caller configuration delegate /// has been applied. /// /// Carries the final along with the source resource. + /// The cancellation token for the build operation. /// A task that completes when post-build processing is done. - Task BuiltAsync(OrchestrationContextBuiltContext context); + Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default); } diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 23748744..0e347ae3 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -52,4 +52,5 @@ description: Initial standalone release notes for the CrestApps.Core repository. - registers shared indexing services in the framework by default, including `ISearchIndexProfileManager`, `ISearchIndexProfileProvisioningService`, and a null fallback `ISearchIndexProfileStore`, so hosts only need `.AddIndexingServices(...).AddYesSqlStores()` or `.AddEntityCoreStores()` when they want persisted index profile records - registers `IAIProfileStore` in the shared AI services layer with a null fallback, and replaces it with provider-backed EntityCore or YesSql stores when AI services data stores are enabled so downstream services can always resolve the profile store - keeps the MVC and Blazor sample-host AI profile, template, and chat-edit screens usable when Claude is not configured by treating failed Claude options validation as "provider unavailable" instead of crashing the page, and removes the legacy memory-settings compatibility shim so profile/template memory state now flows only through `MemoryMetadata` +- keeps the MVC and Blazor sample-host index profile editors aligned with deployment-name-based indexing by posting embedding deployment names instead of catalog IDs and by accepting either selector during embedding profile validation - updates the shared A2A and MCP sample clients so one client app can target either the MVC or Blazor sample host through a built-in server selector, and wires the Aspire AppHost to advertise both endpoints to those samples diff --git a/src/CrestApps.Core.Docs/docs/providers/architecture.md b/src/CrestApps.Core.Docs/docs/providers/architecture.md new file mode 100644 index 00000000..07a35804 --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/providers/architecture.md @@ -0,0 +1,350 @@ +--- +sidebar_label: Architecture +sidebar_position: 2 +title: AI Provider Architecture +description: Capability-based provider model, shared client factory, and credential strategy for CrestApps.Core AI providers. +--- + +# AI Provider Architecture + +> **Status:** Implemented. The capability-flag model (`AIProviderCapability`), the consolidated `IAIProvider` contract, `ProviderBase`, and the deployment-type enforcement inside `DefaultAIClientFactory` ship today. The credential-resolver pipeline (`IProviderCredentialResolver` / `IProviderCredentialResolverSelector` / `ResolvedCredential.Fingerprint`), the abstract `ProviderClientFactory` base, and the `AddCrestAppsAIProvider` registration helper are **deferred to a future pass** — providers continue to read credentials inline, keep their own `BoundedClientCache`, and register themselves manually. + +## Why change + +The current provider model (`IAIClientProvider` + `AIClientProviderBase`) has served well, but a few things have become friction points: + +1. **Implicit capability fan-out.** Every provider must implement five `Get*ClientAsync` methods, even when the underlying SDK has no concept of (e.g.) image generation or speech-to-text. Today providers throw `NotSupportedException` from the unsupported methods. That works, but callers can only discover the lack of support by *trying and catching*. There is no way to ask a provider "do you do embeddings?" up front. +2. **Per-provider boilerplate.** Each provider re-implements the same four moving parts: a `BoundedClientCache`, a cache-key derived from `(endpoint, apiKey)`, a deployment-name fallback (`Get*DeploymentName` from the connection bag), and a credential read (`connection.GetApiKey()` / `connection.GetEndpoint()`). The shape is identical across OpenAI, Azure OpenAI, Claude, DeepSeek, Mistral, Google, and Bedrock. +3. **Credential handling is ad-hoc.** Some providers use API keys, some use Azure managed identities, some use AWS SigV4. The current pattern leaves each provider free to invent its own approach. This makes credential rotation, redaction, and auditing inconsistent. +4. **Provider registration is verbose.** Each provider ships its own `Add*` extension that wires up an `IAIClientProvider`, a connection source, and (for chat) an `IAICompletionClient`. The boilerplate is mechanical and hides the small bits that actually differ per provider. + +## Goals + +- **Discoverable capabilities.** Callers can ask `provider.Supports(AIProviderCapability.Embeddings)` without invoking the underlying SDK. +- **Consolidated infrastructure.** A single shared `ProviderClientFactory` handles caching, key derivation, and credential resolution. Providers contribute a small "build the SDK client from these options" delegate plus the per-capability adapter methods. +- **Consistent credential strategy.** A `IProviderCredentialResolver` abstraction normalises API key / Azure AD / AWS / OAuth flows. Providers declare the credential kinds they accept; the resolver does the rest. +- **Smaller `Add*` surface.** A common `services.AddCrestAppsAIProvider()` extension wires the standard registrations. Providers only override what is genuinely different. +- **Backwards compatible during the migration.** The legacy `IAIClientProvider` keeps working until every in-tree provider has been ported; the new model is additive first. + +## Non-goals + +- Replacing `Microsoft.Extensions.AI.IChatClient` (we will keep delegating to the official `IChatClient` / `IEmbeddingGenerator<,>` / `IImageGenerator` / `ISpeechToTextClient` / `ITextToSpeechClient` types). +- Changing how `AIDeployment` and `AIProviderConnectionEntry` look on the storage side. +- A new credential storage backend — credentials still come from the connection entry (or a `IProviderCredentialResolver` that knows how to read them from elsewhere). +- Migrating to EF Core migrations (covered separately in the data layer plan). + +## Proposed shape + +### 1. Capability flags + +```csharp +namespace CrestApps.Core.AI.Providers; + +[Flags] +public enum AIProviderCapability +{ + None = 0, + Chat = 1 << 0, + Embeddings = 1 << 1, + Images = 1 << 2, + SpeechToText = 1 << 3, + TextToSpeech = 1 << 4, +} +``` + +A `[Flags]` enum lets a provider declare its full surface in one expression and lets call-sites do `provider.Capabilities.HasFlag(AIProviderCapability.Embeddings)`. + +#### Mapping to `AIDeploymentType` + +`AIDeployment.Type` already encodes the deployment surface (`Chat`, `Utility`, `Embedding`, `Image`, `SpeechToText`, `TextToSpeech`). The provider capability flag is the **provider-level** statement; deployment type is the **deployment-level** statement. The factory must validate both. The mapping is fixed: + +| `AIDeploymentType` | Required `AIProviderCapability` | +|---|---| +| `Chat` | `Chat` | +| `Utility` | `Chat` *(utility deployments are chat models with relaxed defaults; no separate provider capability)* | +| `Embedding` | `Embeddings` | +| `Image` | `Images` | +| `SpeechToText` | `SpeechToText` | +| `TextToSpeech` | `TextToSpeech` | + +`DefaultAIClientFactory` enforces this on every `Create*Async` call: it asserts both `provider.Supports(capability)` and `deployment.Type` matches the requested capability before invoking the provider. This makes mismatches surface as a deterministic exception at the factory boundary, not as an SDK-side `BadRequest` deep inside a stream. + +### 2. `IAIProvider` + +```csharp +namespace CrestApps.Core.AI.Providers; + +public interface IAIProvider +{ + string Name { get; } + + AIProviderCapability Capabilities { get; } + + IReadOnlySet AcceptedCredentialKinds { get; } + + bool Supports(AIProviderCapability capability) + => (Capabilities & capability) == capability; + + ValueTask GetChatClientAsync(AIProviderConnectionEntry connection, string deploymentName = null, CancellationToken cancellationToken = default); + ValueTask>> GetEmbeddingGeneratorAsync(AIProviderConnectionEntry connection, string deploymentName = null, CancellationToken cancellationToken = default); + ValueTask GetImageGeneratorAsync(AIProviderConnectionEntry connection, string deploymentName = null, CancellationToken cancellationToken = default); + ValueTask GetSpeechToTextClientAsync(AIProviderConnectionEntry connection, string deploymentName = null, CancellationToken cancellationToken = default); + ValueTask GetTextToSpeechClientAsync(AIProviderConnectionEntry connection, string deploymentName = null, CancellationToken cancellationToken = default); + Task GetSpeechVoicesAsync(AIProviderConnectionEntry connection, string deploymentName = null, CancellationToken cancellationToken = default); +} +``` + +Notes: + +- The `Get*Async` methods stay on the interface (rather than splitting into `IChatProvider`, `IEmbeddingsProvider`, etc.). Splitting would force every consumer to do `provider as IChatProvider` instead of a flag check, and would explode DI registrations. The `Capabilities` flag is the discoverability story; the interface is the *capacity* story. +- A provider that does not declare a capability **must** throw `NotSupportedException` from the corresponding method. The base class enforces this so providers cannot accidentally lie about their flags. +- `CancellationToken` is now part of the contract. The current interface omits it; the new one threads it through (matches the work already done in Phase 5). +- **Lifetime: scoped.** Providers are registered scoped (matching today's `AIClientProviderBase` pattern). They are *thin* — the expensive bits (the cached SDK clients) live on the factory described below, which is registered as a singleton. Providers themselves only adapt the SDK client into `Microsoft.Extensions.AI` types and apply per-request pipeline concerns from the active scope. +- **No raw `IServiceProvider` capture.** The `Microsoft.Extensions.AI` pipeline (`ChatClientBuilder.Build(serviceProvider)`) is now applied by `DefaultAIClientFactory` from the request-scoped service provider, **not** by the provider. Providers return raw SDK adapter clients; the factory layers in middleware/logging/options. This keeps providers free of root-SP capture concerns. +- `AcceptedCredentialKinds` lets the credential resolver/selector know which credential modes the provider can consume. For example, OpenAI returns `{ ApiKey }`; Azure OpenAI returns `{ ApiKey, AzureCredential }`; Bedrock returns `{ AwsCredential }`. + +### 3. `ProviderClientFactory` + +```csharp +namespace CrestApps.Core.AI.Providers; + +public abstract class ProviderClientFactory + where TOptions : class + where TClient : class +{ + private readonly BoundedClientCache _cache = new(); + private readonly IProviderCredentialResolverSelector _credentialSelector; + + protected ProviderClientFactory(IProviderCredentialResolverSelector credentialSelector) + { + _credentialSelector = credentialSelector; + } + + protected ValueTask GetOrCreateAsync( + string providerName, + AIProviderConnectionEntry connection, + string deploymentName, + AIProviderCapability capability, + Func build, + CancellationToken cancellationToken) + { + var resolver = _credentialSelector.Select(providerName, connection); + var credential = resolver.Resolve(providerName, connection, AcceptedCredentialKinds); + var options = ReadOptions(connection, deploymentName, capability); + var key = BuildCacheKey(options, credential); + + return ValueTask.FromResult(_cache.GetOrAdd(key, _ => build(ApplyCredential(options, credential)))); + } + + protected abstract IReadOnlySet AcceptedCredentialKinds { get; } + protected abstract TOptions ReadOptions(AIProviderConnectionEntry connection, string deploymentName, AIProviderCapability capability); + protected abstract TOptions ApplyCredential(TOptions options, ResolvedCredential credential); + protected abstract string BuildCacheKey(TOptions options, ResolvedCredential credential); + + internal void Clear() => _cache.Clear(); +} +``` + +This consolidates today's repeated `BoundedClientCache` + `BuildCacheKey(endpoint, apiKey)` pattern. Each provider supplies the four small abstracts (declare accepted credential kinds, read settings off the connection bag *with deployment name and capability*, fold the credential into the SDK options object, hash the result for caching). + +Two important constraints: + +1. **`deploymentName` and `capability` are first-class inputs to `ReadOptions`.** This is what allows model-bound SDK clients (e.g., Ollama's `OllamaApiClient(endpoint, model)`) to participate in the cache without aliasing across deployments. Providers that own a *root* client (OpenAI, Azure OpenAI) ignore the deployment name in `ReadOptions`/`BuildCacheKey` and pass the deployment to the SDK at the per-call adapter (e.g., `client.GetChatClient(deploymentName)`). +2. **Cache keys never see raw secrets.** `BuildCacheKey` receives `ResolvedCredential`, which exposes a non-secret `Fingerprint` (see §4); provider implementations MUST use `credential.Fingerprint`, never `credential.ApiKey`. This is enforced by code review and a unit test in the test harness that probes each provider's cache-key for known-secret substrings. + +The factory itself is registered as a **singleton** — it's where the cached SDK client objects live across requests. The provider that wraps it is scoped. + +### 4. Credential strategy + +```csharp +namespace CrestApps.Core.AI.Providers; + +public enum CredentialKind +{ + None, + ApiKey, + AzureCredential, + AwsCredential, + OAuthToken, +} + +public sealed class ResolvedCredential +{ + public CredentialKind Kind { get; init; } + + /// + /// Raw API key, when the destination SDK only accepts a plain string. Null otherwise. + /// + public string ApiKey { get; init; } + + /// + /// Native credential object (e.g., Azure.Core.TokenCredential, AWS AWSCredentials). + /// Preferred over whenever the SDK exposes a typed credential. + /// + public object NativeCredential { get; init; } + + /// + /// Deterministic, non-secret fingerprint suitable for cache keys, log lines, and metrics. + /// Computed by the resolver from a salted hash of the underlying secret material. + /// + public string Fingerprint { get; init; } + + /// + /// Typed access to with an explicit cast failure mode. + /// + public T GetNativeCredential() where T : class + => NativeCredential as T + ?? throw new InvalidOperationException($"Resolved credential is not of type {typeof(T)}."); +} + +public interface IProviderCredentialResolver +{ + bool CanResolve(string providerName, AIProviderConnectionEntry connection); + + ResolvedCredential Resolve( + string providerName, + AIProviderConnectionEntry connection, + IReadOnlySet acceptedKinds); +} + +public interface IProviderCredentialResolverSelector +{ + IProviderCredentialResolver Select(string providerName, AIProviderConnectionEntry connection); +} +``` + +The default selector iterates registered `IProviderCredentialResolver`s and picks the first whose `CanResolve` returns true. A `DefaultProviderCredentialResolver` ships in `CrestApps.Core.AI` and handles the `ApiKey` case (reads `connection.GetApiKey()`, fingerprints with SHA-256 over the raw key bytes). Providers that need richer credential modes (Azure managed identity, AWS profile, OAuth refresh) ship their own resolver registered via `services.AddSingleton()`. + +`Fingerprint` is the canonical anchor for everything secret-adjacent: cache keys, structured log fields, and observability counters. The raw `ApiKey` field is set only when the destination SDK forces a string. This dovetails with the `RedactedSecret` work from Phase 1 — secrets never have to round-trip into the AI context bag, and the provider implementation is never responsible for hashing secrets correctly. + +### 5. Provider registration + +```csharp +namespace CrestApps.Core.AI.Providers; + +public sealed class AIProviderRegistration +{ + public string Name { get; init; } + public AIProviderCapability Capabilities { get; init; } + public Action ConfigureProfile { get; init; } + public Action ConfigureConnectionSource { get; init; } +} + +public static class AIProviderServiceCollectionExtensions +{ + public static IServiceCollection AddCrestAppsAIProvider( + this IServiceCollection services, + Action configure) + where TProvider : class, IAIProvider + { + var registration = new AIProviderRegistration(); + configure(registration); + + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.AddCoreAIProfile(registration.Name, registration.ConfigureProfile); + services.AddCoreAIConnectionSource(registration.Name, registration.ConfigureConnectionSource); + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + return services; + } +} +``` + +Each provider package then exposes: + +```csharp +public static IServiceCollection AddCoreAIOllama(this IServiceCollection services) +{ + services.AddSingleton(); + services.AddCrestAppsAIProvider(r => + { + r.Name = OllamaConstants.ClientName; + r.Capabilities = AIProviderCapability.Chat | AIProviderCapability.Embeddings; + r.ConfigureProfile = profile => { /* Ollama-specific profile defaults */ }; + r.ConfigureConnectionSource = source => { /* Ollama-specific connection metadata */ }; + }); + + // Provider-specific extras (e.g., a non-default `IAICompletionClient`) go here only when non-standard. + return services; +} +``` + +Compared to today, the only thing the package owns is the `OllamaProvider` itself, the `OllamaClientFactory`, and any provider-specific completion / response handler. The registration helper covers the boilerplate that today every package re-implements (profile source, connection source, default credential resolver wiring). + +### 6. Reference port: Ollama + +Ollama is the smallest provider surface (chat + embeddings, no auth, no images, no speech) which makes it the cleanest reference port: + +```csharp +public sealed class OllamaProvider : ProviderBase +{ + public override string Name => OllamaConstants.ClientName; + public override AIProviderCapability Capabilities => AIProviderCapability.Chat | AIProviderCapability.Embeddings; + public override IReadOnlySet AcceptedCredentialKinds { get; } = new HashSet { CredentialKind.None }; + + public OllamaProvider(OllamaClientFactory factory) : base(factory) { } + + protected override IChatClient BuildChatClient(OllamaApiClient client, string deploymentName) + => client; // OllamaApiClient already implements IChatClient (model-bound at construction) + + protected override IEmbeddingGenerator> BuildEmbeddingGenerator(OllamaApiClient client, string deploymentName) + => client; // same instance, different facet +} + +internal sealed class OllamaClientFactory : ProviderClientFactory +{ + public OllamaClientFactory(IProviderCredentialResolverSelector selector) : base(selector) { } + + protected override IReadOnlySet AcceptedCredentialKinds { get; } = new HashSet { CredentialKind.None }; + + protected override OllamaClientOptions ReadOptions(AIProviderConnectionEntry c, string deploymentName, AIProviderCapability _) + => new() { Endpoint = c.GetEndpoint(), Model = deploymentName }; + + protected override OllamaClientOptions ApplyCredential(OllamaClientOptions o, ResolvedCredential _) => o; + + // Cache key includes the model because Ollama's SDK client is model-bound at construction. + protected override string BuildCacheKey(OllamaClientOptions o, ResolvedCredential _) + => $"{o.Endpoint.AbsoluteUri}|{o.Model}"; +} +``` + +That replaces the current `OllamaAIClientProvider` end-to-end. All five "unsupported" methods come from the `ProviderBase` base class and throw a consistent `NotSupportedException` with the provider name pre-filled. **`DefaultAIClientFactory` then wraps the returned `IChatClient` with the request-scoped `Microsoft.Extensions.AI` pipeline** (so middleware, logging, and per-call options resolve from the right scope). + +### 7. Deployment-name fallback (all capabilities) + +`AIClientProviderBase` today reads default deployment names from the connection bag for chat, embedding, image, and speech-to-text — but **not** text-to-speech. The new `ProviderBase` normalizes all six capabilities through the same fallback table: + +| Capability | Connection key | +|---|---| +| `Chat` | `ChatDeploymentName` | +| `Chat` (utility) | `UtilityDeploymentName` (then falls back to `ChatDeploymentName`) | +| `Embeddings` | `EmbeddingDeploymentName` | +| `Images` | `ImagesDeploymentName` | +| `SpeechToText` | `SpeechToTextDeploymentName` | +| `TextToSpeech` | `TextToSpeechDeploymentName` *(new)* | + +`GetSpeechVoicesAsync` is treated as part of the `TextToSpeech` capability (no separate flag). + +## Migration plan + +The framework is pre-GA; rather than maintaining a parallel legacy adapter, we land the refactor as one branch with the providers ported in series so each commit builds and tests: + +1. **Add the new types** (`IAIProvider`, `ProviderClientFactory<,>`, `ProviderBase`, `IProviderCredentialResolver`, `IProviderCredentialResolverSelector`, `ResolvedCredential`, `AIProviderRegistration`, `AddCrestAppsAIProvider`) plus the default credential resolver/selector. Wire `DefaultAIClientFactory` to consume `IEnumerable` while keeping its existing `IAIClientProvider`-fed code path active behind the scenes. +2. **Port Ollama** as the reference. Delete `OllamaAIClientProvider` (no `IAIClientProvider` registration left for Ollama). Update tests. +3. **Port the rest in alphabetical order**: Azure AI Inference, Azure OpenAI, Bedrock, Claude, DeepSeek, Google, Mistral, OpenAI. Each port is its own commit; all in-tree tests pass at each step. +4. **Remove the legacy code path** in `DefaultAIClientFactory` and delete `IAIClientProvider` + `AIClientProviderBase`. No `[Obsolete]` shim — pre-GA cleanup. +5. **Add factory-level capability/deployment validation** (the mapping in §1.) and a unit test per provider proving its `BuildCacheKey` does not contain raw secrets. +6. **Docs:** promote this design doc into the canonical "AI provider architecture" page (drop the *(proposal)* suffix); update each per-provider doc with the new flag table. + +## Open questions + +- **Should `Get*Async` methods on providers that don't support a capability return `null` instead of throwing?** Throwing matches the current behaviour and is more discoverable in stack traces, but `null` would let callers do `if (provider.Supports(...))` once and be done. Current preference: keep throwing, since the `Supports` flag is the canonical discovery API and the factory enforces capability before invoking the provider anyway. +- **Should `BoundedClientCache` capacity be configurable per provider?** The shared default (64) is fine for most deployments, but a Bedrock/SageMaker style fan-out might want more. Could surface as `ProviderClientFactoryOptions.MaxCachedClients`. Defer until somebody asks. +- **Provider-specific completion clients.** Today some providers register a non-default `IAICompletionClient` (e.g. for SDK-specific streaming quirks). The new registration helper does *not* register a default `IAICompletionClient` automatically — provider packages keep doing that explicitly when needed. Whether to fold a "default `IChatClient`-backed completion client" into `AddCrestAppsAIProvider` is left as a future cleanup once we see how many providers actually need a custom one. + +## Out of scope + +- Per-provider quota / rate-limit middleware (lives in `Microsoft.Extensions.AI` pipelines today). +- Streaming protocol differences (handled inside each SDK; not a framework concern). +- Server-Sent Events transport hardening (separate work item). diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ExtractedDataOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ExtractedDataOrchestrationHandler.cs index 04e2117a..981cd1ac 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ExtractedDataOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ExtractedDataOrchestrationHandler.cs @@ -31,7 +31,8 @@ public ExtractedDataOrchestrationHandler( /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } @@ -40,7 +41,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public async Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public async Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { if (context.Resource is not AIProfile profile || context.OrchestrationContext.CompletionContext is null || !profile.TryGetSettings(out var settings) || !settings.EnableDataExtraction || settings.DataExtractionEntries.Count == 0 || !context.OrchestrationContext.CompletionContext.AdditionalProperties.TryGetValue("Session", out var sessionObject) || sessionObject is not AIChatSession session || session.ExtractedData.Count == 0) { @@ -54,7 +56,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) } var missingFields = settings.DataExtractionEntries.Where(entry => !session.ExtractedData.TryGetValue(entry.Name, out var state) || state?.Values.Count == 0).Select(entry => new { entry.Name, entry.Description, entry.AllowMultipleValues, entry.IsUpdatable, }).ToList(); - var header = await _templateService.RenderAsync(AITemplateIds.ExtractedDataAvailability, new Dictionary { ["collectedFields"] = collectedFields, ["missingFields"] = missingFields, }); + var header = await _templateService.RenderAsync(AITemplateIds.ExtractedDataAvailability, new Dictionary { ["collectedFields"] = collectedFields, ["missingFields"] = missingFields, }, cancellationToken); if (string.IsNullOrEmpty(header)) { return; diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index ad1bda64..9611a22e 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -1186,7 +1186,7 @@ protected virtual async Task ProcessGeneratedPromptAsync(ChannelWriter(); var completionService = services.GetRequiredService(); (var chatSession, _) = await GetOrCreateSessionAsync(services, sessionId, parentProfile, userPrompt: profile.Name); - var generatedPrompt = await aiTemplateEngine.RenderAsync(profile.PromptTemplate, new Dictionary() { ["Profile"] = profile, ["Session"] = chatSession, }); + var generatedPrompt = await aiTemplateEngine.RenderAsync(profile.PromptTemplate, new Dictionary() { ["Profile"] = profile, ["Session"] = chatSession, }, cancellationToken); var assistantMessage = new AIChatSessionPrompt { ItemId = GenerateId(), @@ -1196,13 +1196,16 @@ protected virtual async Task ProcessGeneratedPromptAsync(ChannelWriter(); var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName, cancellationToken: cancellationToken) ?? throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the profile."); + using var builder = ZString.CreateStringBuilder(); var contentItemIds = new HashSet(); var references = new Dictionary(); + await foreach (var chunk in completionService.CompleteStreamingAsync(chatDeployment, [new ChatMessage(ChatRole.User, generatedPrompt)], completionContext, cancellationToken)) { if (string.IsNullOrEmpty(chunk.Text)) diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs index a1b4d9f8..c0a1eca5 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/DataExtractionService.cs @@ -148,7 +148,7 @@ private static List GetFieldsToExtract(AIProfileDataExtract return ([], false); } - var prompt = await BuildExtractionPromptAsync(fieldsToExtract, session, prompts); + var prompt = await BuildExtractionPromptAsync(fieldsToExtract, session, prompts, cancellationToken); if (string.IsNullOrEmpty(prompt)) { @@ -166,7 +166,7 @@ private static List GetFieldsToExtract(AIProfileDataExtract return ([], false); } - var systemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.DataExtraction); + var systemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.DataExtraction, cancellationToken: cancellationToken); var messages = new List { @@ -288,7 +288,11 @@ private async Task GetChatClientAsync(AIProfile profile) return null; } - private async Task BuildExtractionPromptAsync(List fieldsToExtract, AIChatSession session, IReadOnlyList prompts) + private async Task BuildExtractionPromptAsync( + List fieldsToExtract, + AIChatSession session, + IReadOnlyList prompts, + CancellationToken cancellationToken) { string lastUserMessage = null; string lastAssistantMessage = null; @@ -342,7 +346,7 @@ private async Task BuildExtractionPromptAsync(List arguments["lastAssistantMessage"] = lastAssistantMessage; } - return await _aiTemplateService.RenderAsync(AITemplateIds.DataExtractionPrompt, arguments); + return await _aiTemplateService.RenderAsync(AITemplateIds.DataExtractionPrompt, arguments, cancellationToken); } private sealed class ExtractionResponse diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs index 58536832..73c11883 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/PostSessionProcessingService.cs @@ -89,7 +89,7 @@ public async Task EvaluateResolutionAsync( throw new InvalidOperationException($"Unable to create a chat client for resolution analysis on profile '{profile.ItemId}'."); } - var transcript = await RenderTranscriptAsync(AITemplateIds.ResolutionAnalysisPrompt, prompts); + var transcript = await RenderTranscriptAsync(AITemplateIds.ResolutionAnalysisPrompt, prompts, cancellationToken: cancellationToken); if (string.IsNullOrEmpty(transcript)) { @@ -98,7 +98,7 @@ public async Task EvaluateResolutionAsync( var messages = new List { - new(ChatRole.System, await _aiTemplateService.RenderAsync(AITemplateIds.ResolutionAnalysis)), + new(ChatRole.System, await _aiTemplateService.RenderAsync(AITemplateIds.ResolutionAnalysis, cancellationToken: cancellationToken)), new(ChatRole.User, transcript), }; @@ -151,7 +151,7 @@ public async Task> EvaluateConversionGoalsAsync( ["prompts"] = ProjectPrompts(prompts), }; - var userPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.ConversionGoalEvaluationPrompt, arguments); + var userPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.ConversionGoalEvaluationPrompt, arguments, cancellationToken); if (string.IsNullOrEmpty(userPrompt)) { @@ -160,7 +160,7 @@ public async Task> EvaluateConversionGoalsAsync( var messages = new List { - new(ChatRole.System, await _aiTemplateService.RenderAsync(AITemplateIds.ConversionGoalEvaluation)), + new(ChatRole.System, await _aiTemplateService.RenderAsync(AITemplateIds.ConversionGoalEvaluation, cancellationToken: cancellationToken)), new(ChatRole.User, userPrompt), }; @@ -299,7 +299,7 @@ public async Task> ProcessAsync( ["prompts"] = ProjectPrompts(prompts), }; - var prompt = await _aiTemplateService.RenderAsync(AITemplateIds.PostSessionAnalysisPrompt, arguments); + var prompt = await _aiTemplateService.RenderAsync(AITemplateIds.PostSessionAnalysisPrompt, arguments, cancellationToken); if (string.IsNullOrEmpty(prompt)) { @@ -311,7 +311,7 @@ public async Task> ProcessAsync( return null; } - var systemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.PostSessionAnalysis); + var systemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.PostSessionAnalysis, cancellationToken: cancellationToken); var messages = new List { @@ -874,7 +874,8 @@ private async Task> ResolveToolsAsync(string sessionId, string[] t private async Task RenderTranscriptAsync( string templateId, IReadOnlyList prompts, - Dictionary extraArguments = null) + Dictionary extraArguments = null, + CancellationToken cancellationToken = default) { var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -889,7 +890,7 @@ private async Task RenderTranscriptAsync( } } - return await _aiTemplateService.RenderAsync(templateId, arguments); + return await _aiTemplateService.RenderAsync(templateId, arguments, cancellationToken); } private static List ProjectPrompts(IReadOnlyList prompts) diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs index ac588d5b..5f3fd12a 100644 --- a/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs @@ -10,7 +10,8 @@ internal sealed class ClaudeOrchestrationContextHandler : IOrchestrationContextB /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { if (context.Resource is not ExtensibleEntity entity) { @@ -29,7 +30,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs index 7c022919..b147b7b3 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs @@ -15,7 +15,8 @@ internal sealed class CopilotOrchestrationContextHandler : IOrchestrationContext /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { if (context.Resource is not ExtensibleEntity entity) { @@ -34,7 +35,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs index 395c8262..1ebc5a11 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/GitHubOAuthService.cs @@ -5,6 +5,7 @@ using CrestApps.Core.AI.Copilot.Models; using GitHub.Copilot.SDK; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,11 +17,14 @@ namespace CrestApps.Core.AI.Copilot.Services; public sealed class GitHubOAuthService { private const string ProtectorPurpose = "CrestApps.Core.AI.Copilot.GitHubTokens"; + private const string StateProtectorPurpose = "CrestApps.Core.AI.Copilot.GitHubOAuthState"; + private const string StateCookieName = ".crestapps.gh-oauth-state"; private readonly ICopilotCredentialStore _credentialStore; private readonly IDataProtectionProvider _dataProtectionProvider; private readonly IOptions _options; private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; @@ -39,12 +43,14 @@ public GitHubOAuthService( IOptions options, IHttpClientFactory httpClientFactory, TimeProvider timeProvider, - ILogger logger) + ILogger logger, + IHttpContextAccessor httpContextAccessor) { _credentialStore = credentialStore; _dataProtectionProvider = dataProtectionProvider; _options = options; _httpClientFactory = httpClientFactory; + _httpContextAccessor = httpContextAccessor; _timeProvider = timeProvider; _logger = logger; } @@ -68,6 +74,30 @@ public string GetAuthorizationUrl(string callbackUrl, string returnUrl) var scopes = string.Join(" ", settings.Scopes ?? ["user:email", "read:org"]); var state = returnUrl ?? string.Empty; + var httpContext = _httpContextAccessor?.HttpContext; + + if (httpContext != null) + { + state = Guid.NewGuid().ToString("N"); + + var stateProtector = _dataProtectionProvider.CreateProtector(StateProtectorPurpose); + var statePayload = JsonSerializer.Serialize(new GitHubOAuthState + { + Nonce = state, + ReturnUrl = returnUrl, + ExpiresUtc = _timeProvider.GetUtcNow().AddMinutes(10), + }); + var protectedState = stateProtector.Protect(statePayload); + + httpContext.Response.Cookies.Append(StateCookieName, protectedState, new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = httpContext.Request.IsHttps, + Expires = _timeProvider.GetUtcNow().AddMinutes(10), + }); + } var queryParams = HttpUtility.ParseQueryString(string.Empty); queryParams["client_id"] = settings.ClientId; @@ -78,6 +108,75 @@ public string GetAuthorizationUrl(string callbackUrl, string returnUrl) return $"https://github.com/login/oauth/authorize?{queryParams}"; } + /// + /// Validates the callback state and extracts the safe return URL. + /// + /// The callback state. + /// The validated return URL when the state is valid. + /// when the state contains a safe return URL; otherwise, . + public bool TryValidateCallbackState(string state, out string validatedReturnUrl) + { + var httpContext = _httpContextAccessor?.HttpContext; + + if (httpContext == null) + { + if (string.Equals(state, "__popup__", StringComparison.Ordinal)) + { + validatedReturnUrl = state; + + return true; + } + + if (IsLocalUrl(state)) + { + validatedReturnUrl = state; + + return true; + } + + validatedReturnUrl = null; + + return false; + } + + if (string.IsNullOrWhiteSpace(state) || !httpContext.Request.Cookies.TryGetValue(StateCookieName, out var protectedState) || string.IsNullOrWhiteSpace(protectedState)) + { + validatedReturnUrl = null; + + return false; + } + + try + { + var stateProtector = _dataProtectionProvider.CreateProtector(StateProtectorPurpose); + var payload = JsonSerializer.Deserialize(stateProtector.Unprotect(protectedState)); + + if (payload == null || + !string.Equals(payload.Nonce, state, StringComparison.Ordinal) || + payload.ExpiresUtc < _timeProvider.GetUtcNow()) + { + validatedReturnUrl = null; + + return false; + } + + if (string.Equals(payload.ReturnUrl, "__popup__", StringComparison.Ordinal) || IsLocalUrl(payload.ReturnUrl)) + { + validatedReturnUrl = payload.ReturnUrl; + + return true; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "GitHub OAuth callback state validation failed."); + } + + validatedReturnUrl = null; + + return false; + } + /// /// Exchanges code for token. /// @@ -364,4 +463,20 @@ public async Task> ListModelsAsync( return []; } } + + private static bool IsLocalUrl(string url) + { + return !string.IsNullOrEmpty(url) && + ((url[0] == '/' && (url.Length == 1 || (url[1] != '/' && url[1] != '\\'))) || + (url[0] == '~' && url.Length > 1 && url[1] == '/')); + } + + private sealed class GitHubOAuthState + { + public string Nonce { get; set; } + + public string ReturnUrl { get; set; } + + public DateTimeOffset ExpiresUtc { get; set; } + } } diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentOrchestrationHandler.cs index a6481322..1901a203 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentOrchestrationHandler.cs @@ -47,7 +47,8 @@ public DocumentOrchestrationHandler( /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { if (context.Resource is ChatInteraction interaction && interaction.Documents is { Count: > 0 }) @@ -87,7 +88,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public async Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public async Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { IEnumerable knowledgeBaseDocuments = null; IEnumerable userSuppliedDocuments = null; @@ -171,7 +173,7 @@ sessionObj is AIChatSession session && ["isInScope"] = ragMetadata?.IsInScope == true, }; - var header = await _templateService.RenderAsync(AITemplateIds.DocumentAvailability, arguments); + var header = await _templateService.RenderAsync(AITemplateIds.DocumentAvailability, arguments, cancellationToken); if (!string.IsNullOrEmpty(header)) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/IO/LimitedWriteStream.cs b/src/Primitives/CrestApps.Core.AI.Mcp/IO/LimitedWriteStream.cs new file mode 100644 index 00000000..52eba5b2 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/IO/LimitedWriteStream.cs @@ -0,0 +1,111 @@ +namespace CrestApps.Core.AI.Mcp.IO; + +/// +/// Write-only stream wrapper that enforces a maximum byte budget on the underlying stream. +/// Throws when a write would exceed +/// , allowing callers to abort streaming downloads of untrusted +/// remote files before they exhaust process memory. +/// +public sealed class LimitedWriteStream : Stream +{ + private readonly Stream _inner; + private long _written; + + /// Initializes a new instance of the class. + /// The underlying writable stream that receives the data. + /// The maximum number of bytes that may be written before an exception is thrown. Must be positive. + public LimitedWriteStream(Stream inner, long maxBytes) + { + ArgumentNullException.ThrowIfNull(inner); + + if (maxBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "The byte budget must be positive."); + } + + _inner = inner; + MaxBytes = maxBytes; + } + + /// Gets the maximum number of bytes that may be written to the stream. + public long MaxBytes { get; } + + /// Gets the number of bytes that have been written so far. + public long BytesWritten => _written; + + /// + public override bool CanRead => false; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => true; + + /// + public override long Length => _inner.Length; + + /// + public override long Position + { + get => _inner.Position; + set => throw new NotSupportedException(); + } + + /// + public override void Flush() => _inner.Flush(); + + /// + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + /// + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + /// + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override void Write(byte[] buffer, int offset, int count) + { + EnsureCapacity(count); + _inner.Write(buffer, offset, count); + _written += count; + } + + /// + public override void Write(ReadOnlySpan buffer) + { + EnsureCapacity(buffer.Length); + _inner.Write(buffer); + _written += buffer.Length; + } + + /// + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + EnsureCapacity(count); + await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + _written += count; + } + + /// + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + EnsureCapacity(buffer.Length); + await _inner.WriteAsync(buffer, cancellationToken); + _written += buffer.Length; + } + + private void EnsureCapacity(int additionalBytes) + { + var projected = _written + additionalBytes; + + if (projected > MaxBytes) + { + throw new ResourceSizeLimitExceededException(MaxBytes); + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/IO/ResourceSizeLimitExceededException.cs b/src/Primitives/CrestApps.Core.AI.Mcp/IO/ResourceSizeLimitExceededException.cs new file mode 100644 index 00000000..00fdcea4 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/IO/ResourceSizeLimitExceededException.cs @@ -0,0 +1,19 @@ +namespace CrestApps.Core.AI.Mcp.IO; + +/// +/// Thrown when an MCP resource handler attempts to download more bytes than the +/// configured maximum allowed for a remote resource. +/// +public sealed class ResourceSizeLimitExceededException : Exception +{ + /// Initializes a new instance of the class. + /// The configured maximum number of bytes that may be read. + public ResourceSizeLimitExceededException(long maxBytes) + : base($"The remote resource exceeds the configured maximum size of {maxBytes:N0} bytes.") + { + MaxBytes = maxBytes; + } + + /// Gets the configured maximum number of bytes that may be read for a single resource request. + public long MaxBytes { get; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/RemoteFileResourceHandlerBase.cs b/src/Primitives/CrestApps.Core.AI.Mcp/RemoteFileResourceHandlerBase.cs new file mode 100644 index 00000000..bfb6f37a --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Mcp/RemoteFileResourceHandlerBase.cs @@ -0,0 +1,172 @@ +using CrestApps.Core.AI.Mcp.IO; +using CrestApps.Core.AI.Mcp.Models; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; + +namespace CrestApps.Core.AI.Mcp; + +/// +/// Base class for MCP resource handlers that read a single text-based file from a +/// remote transport (e.g., FTP, SFTP). It centralizes path sanitization, size limits +/// via , MIME-type detection, and consistent error +/// formatting so individual transports only implement the actual download. +/// +/// The connection metadata type carried by the resource. +public abstract class RemoteFileResourceHandlerBase : McpResourceTypeHandlerBase + where TMetadata : class +{ + /// + /// Default upper bound (in bytes) for downloaded resource content when the + /// transport metadata does not specify one. + /// + protected const long DefaultMaxResourceBytes = 5 * 1024 * 1024; + + private static readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of . + /// + /// The resource type identifier this handler serves. + /// The logger used for warnings and diagnostics. + protected RemoteFileResourceHandlerBase(string type, ILogger logger) + : base(type) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + /// + /// Gets a short, human-readable transport name (e.g., "FTP", "SFTP") used in + /// error messages and log entries. + /// + protected abstract string TransportName { get; } + + /// + /// Gets the message returned to the caller when the resource is missing the + /// expected connection metadata. + /// + protected virtual string MissingMetadataMessage + => $"{TransportName} connection metadata is missing."; + + /// + /// Gets the message returned to the caller when the connection metadata does + /// not include a host. + /// + protected virtual string MissingHostMessage + => $"{TransportName} host is required in the connection metadata."; + + /// + protected override async Task GetResultAsync(McpResource resource, IReadOnlyDictionary variables, CancellationToken cancellationToken) + { + if (!resource.TryGet(out var metadata)) + { + return CreateErrorResult(resource.Resource.Uri, MissingMetadataMessage); + } + + if (!TryGetHost(metadata, out var host) || string.IsNullOrEmpty(host)) + { + return CreateErrorResult(resource.Resource.Uri, MissingHostMessage); + } + + var validationError = ValidateMetadata(metadata); + + if (!string.IsNullOrEmpty(validationError)) + { + return CreateErrorResult(resource.Resource.Uri, validationError); + } + + var rawPath = variables.TryGetValue("path", out var pathValue) ? pathValue : string.Empty; + var remotePath = "/" + SanitizePath(rawPath); + var maxBytes = ResolveMaxResourceBytes(metadata); + + using var stream = new MemoryStream(); + await using var limited = new LimitedWriteStream(stream, maxBytes); + + try + { + await DownloadAsync(metadata, host, remotePath, limited, cancellationToken); + } + catch (ResourceSizeLimitExceededException ex) + { + _logger.LogWarning(ex, "{Transport} resource {ResourceId} exceeded the maximum allowed size of {MaxBytes} bytes.", TransportName, resource.ItemId, ex.MaxBytes); + + return CreateErrorResult(resource.Resource.Uri, $"The requested {TransportName} resource exceeds the maximum allowed size of {ex.MaxBytes:N0} bytes."); + } + + stream.Position = 0; + + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync(cancellationToken); + + var mimeType = resource.Resource?.MimeType; + + if (string.IsNullOrEmpty(mimeType) && !_contentTypeProvider.TryGetContentType(remotePath, out mimeType)) + { + mimeType = "application/octet-stream"; + } + + return new ReadResourceResult + { + Contents = + [ + new TextResourceContents + { + Uri = resource.Resource.Uri, + MimeType = mimeType, + Text = content, + } + ] + }; + } + + /// + /// Resolves the maximum allowed download size, in bytes, for the supplied metadata. + /// + /// The connection metadata. + /// The configured maximum or when none is set. + protected virtual long ResolveMaxResourceBytes(TMetadata metadata) + { + var configured = GetMaxResourceBytes(metadata); + + return configured is > 0 ? configured.Value : DefaultMaxResourceBytes; + } + + /// + /// Performs additional metadata validation before attempting to download. + /// Return a non-empty string to short-circuit with an error response. + /// + /// The connection metadata. + /// An error message, or null when the metadata is valid. + protected virtual string ValidateMetadata(TMetadata metadata) => null; + + /// + /// Gets the per-resource size cap, if one is configured by the metadata. + /// + /// The connection metadata. + /// The configured size cap, or null to use the default. + protected abstract long? GetMaxResourceBytes(TMetadata metadata); + + /// + /// Extracts the host portion from the metadata. + /// + /// The connection metadata. + /// The extracted host value. + /// true when a host was extracted; otherwise, false. + protected abstract bool TryGetHost(TMetadata metadata, out string host); + + /// + /// Connects to the remote transport and writes the requested file to the supplied stream. + /// Implementations must observe and may throw + /// indirectly via the destination stream. + /// + /// The connection metadata. + /// The validated host extracted by . + /// The sanitized remote path to download. + /// The size-limited destination stream. + /// A cancellation token. + protected abstract Task DownloadAsync(TMetadata metadata, string host, string remotePath, Stream destination, CancellationToken cancellationToken); +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs index 732bc721..930e7a5c 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs @@ -45,7 +45,8 @@ public AIMemoryOrchestrationHandler( /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } @@ -54,7 +55,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public async Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public async Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { if (context.OrchestrationContext.CompletionContext is null) { @@ -92,7 +94,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) AIInvocationScope.Current?.Items.TryAdd(MemoryConstants.CompletionContextKeys.UserId, userId); var memoryTools = _toolDefinitions.Tools.Where(t => t.Value.HasPurpose(AIToolPurposes.Memory)).Select(t => t.Value).ToList(); context.OrchestrationContext.MustIncludeTools.AddRange(memoryTools.Select(tool => tool.Name)); - var header = await _templateService.RenderAsync(MemoryConstants.TemplateIds.MemoryAvailability, new Dictionary { ["tools"] = memoryTools, ["searchToolName"] = SearchUserMemoriesTool.TheName, ["listToolName"] = ListUserMemoriesTool.TheName, ["saveToolName"] = SaveUserMemoryTool.TheName, ["removeToolName"] = RemoveUserMemoryTool.TheName, }); + var header = await _templateService.RenderAsync(MemoryConstants.TemplateIds.MemoryAvailability, new Dictionary { ["tools"] = memoryTools, ["searchToolName"] = SearchUserMemoriesTool.TheName, ["listToolName"] = ListUserMemoriesTool.TheName, ["saveToolName"] = SaveUserMemoryTool.TheName, ["removeToolName"] = RemoveUserMemoryTool.TheName, }, cancellationToken); if (!string.IsNullOrEmpty(header)) { context.OrchestrationContext.SystemMessageBuilder.AppendLine(); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolExecutionContextOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolExecutionContextOrchestrationHandler.cs index 4ef529c3..221edfae 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIToolExecutionContextOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIToolExecutionContextOrchestrationHandler.cs @@ -15,7 +15,8 @@ internal sealed class AIToolExecutionContextOrchestrationHandler : IOrchestratio /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } @@ -24,7 +25,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { var invocationContext = AIInvocationScope.Current; if (invocationContext is null) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs index f447ae74..7ae0e3ef 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs @@ -32,7 +32,8 @@ public AgentOrchestrationContextBuilderHandler( /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } @@ -41,7 +42,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public async Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public async Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { var completionContext = context.OrchestrationContext.CompletionContext; if (completionContext is null) @@ -50,7 +52,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) } var requestedAgentNames = completionContext.AgentNames; - var agents = await _profileManager.GetAsync(AIProfileType.Agent); + var agents = await _profileManager.GetAsync(AIProfileType.Agent, cancellationToken); if (!agents.Any()) { return; @@ -82,7 +84,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) _logger.LogDebug("Enriching system message with {AgentCount} available agent(s).", availableAgents.Count); } - var header = await _templateService.RenderAsync(AITemplateIds.AgentAvailability, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["agents"] = availableAgents, }); + var header = await _templateService.RenderAsync(AITemplateIds.AgentAvailability, new Dictionary(StringComparer.OrdinalIgnoreCase) { ["agents"] = availableAgents, }, cancellationToken); if (!string.IsNullOrEmpty(header)) { context.OrchestrationContext.SystemMessageBuilder.AppendLine(); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/CompletionContextOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/CompletionContextOrchestrationHandler.cs index 7536b1ae..5df58f1b 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/CompletionContextOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/CompletionContextOrchestrationHandler.cs @@ -26,10 +26,11 @@ public CompletionContextOrchestrationHandler(IAICompletionContextBuilder complet /// Buildings the operation. /// /// The context. - public async Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public async Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { // Build the AICompletionContext using the existing handler pipeline. - var completionContext = await _completionContextBuilder.BuildAsync(context.Resource); + var completionContext = await _completionContextBuilder.BuildAsync(context.Resource, cancellationToken: cancellationToken); context.Context.CompletionContext = completionContext; // Propagate DisableTools from the completion context. @@ -46,7 +47,8 @@ public async Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs index 429d1f11..279455f6 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs @@ -33,7 +33,8 @@ public DataSourceOrchestrationHandler( /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } @@ -42,7 +43,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The context. - public async Task BuiltAsync(OrchestrationContextBuiltContext context) + /// The cancellation token. + public async Task BuiltAsync(OrchestrationContextBuiltContext context, CancellationToken cancellationToken = default) { if (context.OrchestrationContext.CompletionContext == null || string.IsNullOrWhiteSpace(context.OrchestrationContext.CompletionContext.DataSourceId)) { @@ -64,7 +66,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) arguments["searchToolName"] = SystemToolNames.SearchDataSources; } - var header = await _templateService.RenderAsync(AITemplateIds.DataSourceAvailability, arguments); + var header = await _templateService.RenderAsync(AITemplateIds.DataSourceAvailability, arguments, cancellationToken); if (!string.IsNullOrEmpty(header)) { context.OrchestrationContext.SystemMessageBuilder.AppendLine(); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs index 7449f0aa..c0294a1e 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs @@ -49,7 +49,8 @@ public PreemptiveRagOrchestrationHandler( /// Buildings the operation. /// /// The context. - public Task BuildingAsync(OrchestrationContextBuildingContext context) + /// The cancellation token. + public Task BuildingAsync(OrchestrationContextBuildingContext context, CancellationToken cancellationToken = default) { return Task.CompletedTask; } @@ -58,7 +59,8 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) /// Builts the operation. /// /// The build context. - public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) + /// The cancellation token. + public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(buildContext.OrchestrationContext.UserMessage)) { @@ -91,7 +93,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) // search but still inject instructions so the model knows to call search tools. if (!_settings.EnablePreemptiveRag && !buildContext.OrchestrationContext.DisableTools) { - await InjectToolSearchInstructionsAsync(buildContext, ragMetadata); + await InjectToolSearchInstructionsAsync(buildContext, ragMetadata, cancellationToken); return; } @@ -121,7 +123,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) var hasAnyRefs = buildContext.OrchestrationContext.Properties.ContainsKey("DataSourceReferences") || buildContext.OrchestrationContext.Properties.ContainsKey("DocumentReferences"); if (hasAnyRefs) { - var prompt = await _templateService.RenderAsync(AITemplateIds.RagResponseGuidelines); + var prompt = await _templateService.RenderAsync(AITemplateIds.RagResponseGuidelines, cancellationToken: cancellationToken); if (!string.IsNullOrEmpty(prompt)) { buildContext.OrchestrationContext.SystemMessageBuilder.AppendLine(); @@ -138,7 +140,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) { if (buildContext.OrchestrationContext.DisableTools) { - var prompt = await _templateService.RenderAsync(AITemplateIds.RagScopeNoRefsToolsDisabled); + var prompt = await _templateService.RenderAsync(AITemplateIds.RagScopeNoRefsToolsDisabled, cancellationToken: cancellationToken); if (!string.IsNullOrEmpty(prompt)) { buildContext.OrchestrationContext.SystemMessageBuilder.AppendLine(); @@ -147,7 +149,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) } else { - var prompt = await _templateService.RenderAsync(AITemplateIds.RagScopeNoRefsToolsEnabled, CreateSearchToolArguments()); + var prompt = await _templateService.RenderAsync(AITemplateIds.RagScopeNoRefsToolsEnabled, CreateSearchToolArguments(), cancellationToken); if (!string.IsNullOrEmpty(prompt)) { buildContext.OrchestrationContext.SystemMessageBuilder.AppendLine(); @@ -157,7 +159,7 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) } else { - var prompt = await _templateService.RenderAsync(AITemplateIds.RagScopeWithRefs); + var prompt = await _templateService.RenderAsync(AITemplateIds.RagScopeWithRefs, cancellationToken: cancellationToken); if (!string.IsNullOrEmpty(prompt)) { buildContext.OrchestrationContext.SystemMessageBuilder.AppendLine(); @@ -166,12 +168,12 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext buildContext) } } - private async Task InjectToolSearchInstructionsAsync(OrchestrationContextBuiltContext buildContext, AIDataSourceRagMetadata ragMetadata) + private async Task InjectToolSearchInstructionsAsync(OrchestrationContextBuiltContext buildContext, AIDataSourceRagMetadata ragMetadata, CancellationToken cancellationToken) { if (ragMetadata?.IsInScope == true) { // IsInScope ON: the model MUST call search tools and MUST NOT use general knowledge. - var prompt = await _templateService.RenderAsync(AITemplateIds.RagToolSearchStrict, CreateSearchToolArguments()); + var prompt = await _templateService.RenderAsync(AITemplateIds.RagToolSearchStrict, CreateSearchToolArguments(), cancellationToken); if (!string.IsNullOrEmpty(prompt)) { buildContext.OrchestrationContext.SystemMessageBuilder.AppendLine(); @@ -181,7 +183,7 @@ private async Task InjectToolSearchInstructionsAsync(OrchestrationContextBuiltCo else { // IsInScope OFF: the model MUST try search tools first, then may supplement with general knowledge. - var prompt = await _templateService.RenderAsync(AITemplateIds.RagToolSearchRelaxed, CreateSearchToolArguments()); + var prompt = await _templateService.RenderAsync(AITemplateIds.RagToolSearchRelaxed, CreateSearchToolArguments(), cancellationToken); if (!string.IsNullOrEmpty(prompt)) { buildContext.OrchestrationContext.SystemMessageBuilder.AppendLine(); diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs index aa09f45e..2015d2b7 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs @@ -58,7 +58,7 @@ public override async ValueTask ValidateAsync(SearchIndexProfile indexProfile, V return; } - var deployment = await _deploymentCatalog.FindByNameAsync(indexProfile.EmbeddingDeploymentName, cancellationToken); + var deployment = await ResolveDeploymentAsync(indexProfile.EmbeddingDeploymentName, cancellationToken); if (deployment == null) { result.Fail(new ValidationResult("The selected embedding deployment could not be found.", [nameof(SearchIndexProfile.EmbeddingDeploymentName)])); @@ -112,7 +112,7 @@ protected bool CanHandle(SearchIndexProfile indexProfile) protected abstract IReadOnlyCollection BuildFields(int vectorDimensions); private async Task GetEmbeddingDimensionsAsync(SearchIndexProfile indexProfile, CancellationToken cancellationToken) { - var deployment = await _deploymentCatalog.FindByNameAsync(indexProfile.EmbeddingDeploymentName, cancellationToken); + var deployment = await ResolveDeploymentAsync(indexProfile.EmbeddingDeploymentName, cancellationToken); if (deployment == null) { throw new InvalidOperationException("The selected embedding deployment could not be found."); @@ -137,4 +137,11 @@ private async Task GetEmbeddingDimensionsAsync(SearchIndexProfile indexProf throw new InvalidOperationException("The selected embedding deployment did not return a valid embedding vector."); } + + private async ValueTask ResolveDeploymentAsync(string selector, CancellationToken cancellationToken) + { + var deployment = await _deploymentCatalog.FindByNameAsync(selector, cancellationToken); + + return deployment ?? await _deploymentCatalog.FindByIdAsync(selector, cancellationToken); + } } diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/DefaultOrchestrator.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/DefaultOrchestrator.cs index dc2104a6..d7c22a1f 100644 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/DefaultOrchestrator.cs +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/DefaultOrchestrator.cs @@ -187,7 +187,7 @@ internal async Task PlanAsync( }).ToList(), }; - var planningSystemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.TaskPlanning, arguments); + var planningSystemPrompt = await _aiTemplateService.RenderAsync(AITemplateIds.TaskPlanning, arguments, cancellationToken); var chatClient = await GetUtilityChatClientAsync(context); diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 51b89cab..9080b2b9 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -59,6 +59,13 @@ public static IServiceCollection AddCoreAITemplating( entry.Description = new LocalizedString(AITemplateSources.SystemPrompt, "Create a reusable system prompt template."); }); + services.Configure(options => + { + Fluid.MemberAccessStrategyExtensions.Register(options.MemberAccessStrategy); + Fluid.MemberAccessStrategyExtensions.Register(options.MemberAccessStrategy); + Fluid.MemberAccessStrategyExtensions.Register(options.MemberAccessStrategy); + }); + services.TryAddScoped(); services.TryAddScoped>(sp => sp.GetRequiredService()); services.TryAddScoped>(sp => sp.GetRequiredService()); diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIChatResponseHandler.cs b/src/Primitives/CrestApps.Core.AI/Services/AIChatResponseHandler.cs index 3b423402..99f87eb7 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIChatResponseHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIChatResponseHandler.cs @@ -39,12 +39,15 @@ public async Task HandleAsync( if (context.ChatType == ChatContextType.AIChatSession) { - orchestratorContext = await orchestrationContextBuilder.BuildAsync(context.Profile, ctx => - { - ctx.UserMessage = context.Prompt; - ctx.ConversationHistory = context.ConversationHistory; - ctx.CompletionContext.AdditionalProperties[AICompletionContextKeys.Session] = context.ChatSession; - }); + orchestratorContext = await orchestrationContextBuilder.BuildAsync( + context.Profile, + ctx => + { + ctx.UserMessage = context.Prompt; + ctx.ConversationHistory = context.ConversationHistory; + ctx.CompletionContext.AdditionalProperties[AICompletionContextKeys.Session] = context.ChatSession; + }, + cancellationToken); orchestratorName = context.Profile.OrchestratorName; @@ -56,11 +59,14 @@ public async Task HandleAsync( } else { - orchestratorContext = await orchestrationContextBuilder.BuildAsync(context.Interaction, ctx => - { - ctx.UserMessage = context.Prompt; - ctx.ConversationHistory = context.ConversationHistory; - }); + orchestratorContext = await orchestrationContextBuilder.BuildAsync( + context.Interaction, + ctx => + { + ctx.UserMessage = context.Prompt; + ctx.ConversationHistory = context.ConversationHistory; + }, + cancellationToken); orchestratorName = context.Interaction.OrchestratorName; diff --git a/src/Primitives/CrestApps.Core.AI/Services/BoundedClientCache.cs b/src/Primitives/CrestApps.Core.AI/Services/BoundedClientCache.cs new file mode 100644 index 00000000..f17ee7db --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/BoundedClientCache.cs @@ -0,0 +1,138 @@ +namespace CrestApps.Core.AI.Services; + +/// +/// A thread-safe, bounded least-recently-used (LRU) cache used by AI provider +/// client factories to avoid unbounded growth of long-lived SDK client instances +/// keyed by connection fingerprint. +/// +/// The cached client type. +internal sealed class BoundedClientCache where TClient : class +{ + private readonly int _capacity; + private readonly Dictionary> _map; + private readonly LinkedList _order = new(); + private readonly Lock _syncLock = new(); + + public BoundedClientCache(int capacity = 64) + { + if (capacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Capacity must be greater than zero."); + } + + _capacity = capacity; + _map = new Dictionary>(StringComparer.Ordinal); + } + + public int Count + { + get + { + lock (_syncLock) + { + return _map.Count; + } + } + } + + public TClient GetOrAdd(string key, Func factory) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(factory); + + lock (_syncLock) + { + if (_map.TryGetValue(key, out var existing)) + { + _order.Remove(existing); + _order.AddLast(existing); + + return existing.Value.Client; + } + } + + var created = factory(key); + + lock (_syncLock) + { + if (_map.TryGetValue(key, out var existing)) + { + _order.Remove(existing); + _order.AddLast(existing); + DisposeIfNeeded(created); + + return existing.Value.Client; + } + + var node = new LinkedListNode(new Entry(key, created)); + _map[key] = node; + _order.AddLast(node); + + while (_map.Count > _capacity) + { + var first = _order.First; + if (first is null) + { + break; + } + + _order.RemoveFirst(); + _map.Remove(first.Value.Key); + DisposeIfNeeded(first.Value.Client); + } + + return created; + } + } + + public void Clear() + { + TClient[] toDispose; + + lock (_syncLock) + { + toDispose = new TClient[_map.Count]; + var i = 0; + foreach (var node in _map.Values) + { + toDispose[i++] = node.Value.Client; + } + + _map.Clear(); + _order.Clear(); + } + + foreach (var client in toDispose) + { + DisposeIfNeeded(client); + } + } + + private static void DisposeIfNeeded(TClient client) + { + if (client is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch + { + // Best-effort disposal; swallow to avoid disrupting cache eviction. + } + } + } + + private readonly struct Entry + { + public Entry(string key, TClient client) + { + Key = key; + Client = client; + } + + public string Key { get; } + + public TClient Client { get; } + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultOrchestrationContextBuilder.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultOrchestrationContextBuilder.cs index 1e96d28a..000a3ba2 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultOrchestrationContextBuilder.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultOrchestrationContextBuilder.cs @@ -35,7 +35,8 @@ public DefaultOrchestrationContextBuilder( /// /// The resource. /// The configure. - public async ValueTask BuildAsync(object resource, Action configure = null) + /// The cancellation token. + public async ValueTask BuildAsync(object resource, Action configure = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(resource); @@ -50,7 +51,7 @@ public async ValueTask BuildAsync(object resource, Action< { try { - await handler.BuildingAsync(building); + await handler.BuildingAsync(building, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -66,7 +67,7 @@ public async ValueTask BuildAsync(object resource, Action< { try { - await handler.BuiltAsync(built); + await handler.BuiltAsync(built, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs b/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs index 78d9b3dd..f64cbc6d 100644 --- a/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs +++ b/src/Primitives/CrestApps.Core.AI/Tools/GenerateChartTool.cs @@ -111,7 +111,7 @@ protected override async ValueTask InvokeCoreAsync( var promptService = arguments.Services.GetService(); var systemPrompt = promptService != null - ? await promptService.RenderAsync(AITemplateIds.ChartGeneration) + ? await promptService.RenderAsync(AITemplateIds.ChartGeneration, cancellationToken: cancellationToken) : string.Empty; var messages = new List diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs index 791e4cd9..2435983c 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs @@ -6,7 +6,7 @@ namespace CrestApps.Core.Elasticsearch.Services; /// -/// Creates Elasticsearch clients from the current connection options. +/// Creates Elasticsearch clients from the configured connection options. /// public sealed class ElasticsearchClientFactory : IElasticsearchClientFactory { diff --git a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs index 5a9dd671..3a47ba8f 100644 --- a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs +++ b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs @@ -56,7 +56,11 @@ public static string GetStringValue(this IDictionary entry, stri if (entry.TryGetValue(key, out var value)) { string stringValue; - if (value is JsonElement jsonElement) + if (value is RedactedSecret redacted) + { + stringValue = redacted.Reveal(); + } + else if (value is JsonElement jsonElement) { stringValue = jsonElement.GetString(); } diff --git a/src/Primitives/CrestApps.Core.Infrastructure/RedactedSecret.cs b/src/Primitives/CrestApps.Core.Infrastructure/RedactedSecret.cs new file mode 100644 index 00000000..47a4604f --- /dev/null +++ b/src/Primitives/CrestApps.Core.Infrastructure/RedactedSecret.cs @@ -0,0 +1,47 @@ +namespace CrestApps.Core.Infrastructure; + +/// +/// Holds a secret value (for example, an API key) in a wrapper whose +/// renders a masked sentinel instead of the raw value. +/// Use this anywhere a plaintext credential could otherwise leak through +/// generic logging, JSON serialization, or object-typed property bags. +/// Call at the credential boundary to read the secret. +/// +public sealed class RedactedSecret +{ + private const string Mask = "***"; + + private readonly string _value; + + /// Initializes a new instance of the class. + /// The plaintext secret to wrap. May be null or empty. + public RedactedSecret(string value) + { + _value = value; + } + + /// + /// Returns the unredacted secret value. Use only at credential boundaries. + /// + public string Reveal() => _value; + + /// + /// Returns true when the wrapped secret is null or empty. + /// + public bool IsEmpty => string.IsNullOrEmpty(_value); + + /// + /// Returns a redacted string representation of the secret, never the raw value. + /// + public override string ToString() => Mask; + + /// + /// Convenience factory that returns null for null/empty inputs to keep call sites tidy. + /// + /// The plaintext secret. + /// A wrapping the value, or null when is null or empty. + public static RedactedSecret CreateOrNull(string value) + { + return string.IsNullOrEmpty(value) ? null : new RedactedSecret(value); + } +} diff --git a/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs index 7321aad2..71f36617 100644 --- a/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using CrestApps.Core.Templates.Providers; using CrestApps.Core.Templates.Rendering; using CrestApps.Core.Templates.Services; +using CrestApps.Core.Templates.Tags; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -39,6 +40,13 @@ public static IServiceCollection AddTemplating( services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.Configure(options => + { + if (!options.Filters.TryGetValue(IncludeTemplateFilter.FilterName, out _)) + { + options.Filters.AddFilter(IncludeTemplateFilter.FilterName, IncludeTemplateFilter.IncludePromptAsync); + } + }); if (configure != null) { diff --git a/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs b/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs index d6397042..6e479745 100644 --- a/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs +++ b/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs @@ -21,10 +21,10 @@ public sealed class EmbeddedResourceTemplateProvider : ITemplateProvider /// /// Initializes a new instance of the class. /// - /// The assembly. - /// The parsers. - /// The source. - /// The feature id. + /// The assembly to scan for embedded templates. + /// The template parsers. + /// The logical source name to assign to discovered templates. + /// The feature identifier to assign to discovered templates. public EmbeddedResourceTemplateProvider( Assembly assembly, IEnumerable parsers, @@ -38,9 +38,11 @@ public EmbeddedResourceTemplateProvider( } /// - /// Gets templates. + /// Gets the templates discovered from embedded resources. /// - public Task> GetTemplatesAsync() + /// The cancellation token. + /// The discovered templates. + public Task> GetTemplatesAsync(CancellationToken cancellationToken = default) { var templates = new List