From 481ce399149cbbeeab2c659f31dc77202c829318 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 3 Aug 2026 21:50:16 +0300 Subject: [PATCH 1/6] Introduce metadata-driven AI model features and parameters Replaces hardcoded, provider-specific model options with an extensible registry of model features (binary capabilities) and model parameters (configurable options carrying kind, allowed values, ranges, and defaults). AI deployments declare which registered definitions their model exposes and may narrow the allowed values, default, or numeric bounds. AI profiles, profile templates, and chat interactions store the selected values, and the runtime binds them into the outgoing request. Values for parameters a deployment does not expose are never rendered and never sent. Framework: - adds the capability abstractions, descriptors, and metadata models - adds AddCoreAIModelCapabilities, AddAIModelFeature, and AddAIModelParameter - registers eight built-in features and the reasoningEffort parameter - adds DefaultAIModelCapabilityService to merge registrations with deployment metadata without mutating the registered descriptors - adds ModelParametersAICompletionServiceHandler as the single enforcement point, with deployment-default fallback and binder dispatch - adds IAIModelParameterBinder and a reasoning effort binder that sets ChatOptions.Reasoning.Effort - maps the resolved effort onto ChatCompletionOptions.ReasoningEffortLevel in AzureOpenAICompletionClient so both request paths behave the same - adds CompletionServiceConfigureContext.Deployment - adds a ModelParameters front-matter key to the markdown template parser Sample hosts: - adds a deployment capability editor and a metadata-driven parameter editor to the AI deployment, profile, template, and chat interaction screens in CrestApps.Core.Mvc.Web - adds the equivalent ModelCapabilitiesEditor and ModelParametersEditor components to CrestApps.Core.Blazor.Web Docs: - adds the Model Capabilities guide and records the change in the 1.1.0 changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AIModelParameterBindingContext.cs | 59 +++ .../Capabilities/IAIModelCapabilityService.cs | 33 ++ .../Capabilities/IAIModelParameterBinder.cs | 21 + .../AICompletionContextExtensions.cs | 35 ++ .../Models/AICompletionContext.cs | 7 + .../Models/AIDeploymentCapabilities.cs | 79 ++++ .../Models/AIDeploymentModelMetadata.cs | 46 ++ .../Models/AIDeploymentModelParameter.cs | 52 +++ .../Models/AIModelCapabilityOptions.cs | 78 ++++ .../Models/AIModelFeatureDescriptor.cs | 41 ++ .../Models/AIModelFeatureNames.cs | 48 +++ .../Models/AIModelParameterDescriptor.cs | 135 ++++++ .../Models/AIModelParameterKind.cs | 33 ++ .../Models/AIModelParameterNames.cs | 23 + .../Models/AIModelParameterOption.cs | 25 ++ .../Models/AIModelParametersMetadata.cs | 30 ++ .../CompletionServiceConfigureContext.cs | 5 + .../docs/changelog/1.1.0.md | 40 ++ .../docs/core/ai-model-capabilities.md | 323 ++++++++++++++ src/CrestApps.Core.Docs/sidebars.js | 1 + ...eractionCompletionContextBuilderHandler.cs | 6 + .../Services/AzureOpenAICompletionClient.cs | 63 ++- ...IProfileCompletionContextBuilderHandler.cs | 5 + ...delParametersAICompletionServiceHandler.cs | 94 ++++ .../ServiceCollectionExtensions.cs | 136 ++++++ .../Services/AIProfileTemplateParser.cs | 39 ++ .../DefaultAIModelCapabilityService.cs | 143 +++++++ .../Services/NamedAICompletionClient.cs | 11 +- .../ReasoningEffortModelParameterBinder.cs | 33 ++ .../Forms/ModelCapabilitiesEditor.razor | 187 ++++++++ .../Forms/ModelParametersEditor.razor | 158 +++++++ .../Pages/AI/AIDeployments/Create.razor | 2 + .../Pages/AI/AIDeployments/Edit.razor | 2 + .../Pages/AI/AIProfiles/Create.razor | 15 + .../Components/Pages/AI/AIProfiles/Edit.razor | 4 + .../Pages/AI/Templates/Create.razor | 4 + .../Components/Pages/AI/Templates/Edit.razor | 4 + .../Pages/ChatInteractions/Create.razor | 13 + .../ViewModels/AIDeploymentViewModel.cs | 194 +++++++++ .../ViewModels/AIProfileViewModel.cs | 20 + .../ViewModels/AITemplateViewModel.cs | 19 + .../ViewModels/ChatInteractionViewModel.cs | 6 + .../AI/Controllers/AIDeploymentController.cs | 8 +- .../AI/Controllers/AIProfileController.cs | 20 +- .../AI/Controllers/AITemplateController.cs | 9 +- .../AI/ViewModels/AIDeploymentViewModel.cs | 238 +++++++++++ .../Areas/AI/ViewModels/AIProfileViewModel.cs | 27 ++ .../AI/ViewModels/AITemplateViewModel.cs | 27 ++ .../Areas/AI/Views/AIDeployment/Create.cshtml | 2 + .../Areas/AI/Views/AIDeployment/Edit.cshtml | 2 + .../Areas/AI/Views/AIProfile/Create.cshtml | 6 + .../Areas/AI/Views/AIProfile/Edit.cshtml | 6 + .../Areas/AI/Views/AITemplate/Create.cshtml | 6 + .../Areas/AI/Views/AITemplate/Edit.cshtml | 6 + .../Controllers/ChatInteractionController.cs | 17 +- .../ViewModels/ChatInteractionViewModel.cs | 13 + .../Views/ChatInteraction/Create.cshtml | 6 + .../Models/ModelParameterEditorViewModel.cs | 138 ++++++ .../Services/AIModelParameterViewService.cs | 113 +++++ .../YesSqlServiceCollectionExtensions.cs | 3 +- .../_DeploymentModelCapabilities.cshtml | 180 ++++++++ .../Views/Shared/_ModelParameters.cshtml | 208 +++++++++ .../Framework/AI/AIModelCapabilityTests.cs | 400 ++++++++++++++++++ .../Mvc/AIProviderConnectionOptionsTests.cs | 15 +- 64 files changed, 3689 insertions(+), 33 deletions(-) create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/AIModelParameterBindingContext.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelCapabilityService.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelParameterBinder.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/AICompletionContextExtensions.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCapabilities.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelMetadata.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelParameter.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelCapabilityOptions.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterKind.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterNames.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterOption.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParametersMetadata.cs create mode 100644 src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md create mode 100644 src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Services/DefaultAIModelCapabilityService.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Services/ReasoningEffortModelParameterBinder.cs create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_ModelParameters.cshtml create mode 100644 tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/AIModelParameterBindingContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/AIModelParameterBindingContext.cs new file mode 100644 index 00000000..22b8dc5c --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/AIModelParameterBindingContext.cs @@ -0,0 +1,59 @@ +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Capabilities; + +/// +/// Carries the information required by an to apply a selected +/// model parameter value to the outgoing request. +/// +public sealed class AIModelParameterBindingContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The effective descriptor of the parameter being applied. + /// The value selected by the operator. + /// The chat options to mutate. + /// The completion context of the current request. + public AIModelParameterBindingContext( + AIModelParameterDescriptor descriptor, + string value, + ChatOptions chatOptions, + AICompletionContext completionContext) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(chatOptions); + ArgumentNullException.ThrowIfNull(completionContext); + + Descriptor = descriptor; + Value = value; + ChatOptions = chatOptions; + CompletionContext = completionContext; + } + + /// + /// Gets the effective descriptor of the parameter being applied. + /// + public AIModelParameterDescriptor Descriptor { get; } + + /// + /// Gets the value selected by the operator. + /// + public string Value { get; } + + /// + /// Gets the chat options to mutate. + /// + public ChatOptions ChatOptions { get; } + + /// + /// Gets the completion context of the current request. + /// + public AICompletionContext CompletionContext { get; } + + /// + /// Gets or sets the deployment resolved for the current request. + /// + public AIDeployment Deployment { get; set; } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelCapabilityService.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelCapabilityService.cs new file mode 100644 index 00000000..ad24c7b1 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelCapabilityService.cs @@ -0,0 +1,33 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Capabilities; + +/// +/// Resolves the metadata-driven capabilities of an by merging the globally +/// registered model features and parameters with the metadata stored on the deployment. +/// +public interface IAIModelCapabilityService +{ + /// + /// Gets every model feature registered by the application. + /// + IReadOnlyList GetRegisteredFeatures(); + + /// + /// Gets every model parameter registered by the application. + /// + IReadOnlyList GetRegisteredParameters(); + + /// + /// Gets the effective capabilities exposed by the given deployment. + /// + /// The deployment to inspect. + AIDeploymentCapabilities GetCapabilities(AIDeployment deployment); + + /// + /// Gets the effective capabilities exposed by the deployment with the given technical name. + /// + /// The technical name of the deployment. + /// The token to monitor for cancellation requests. + ValueTask GetCapabilitiesAsync(string deploymentName, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelParameterBinder.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelParameterBinder.cs new file mode 100644 index 00000000..d5e6bfe2 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Capabilities/IAIModelParameterBinder.cs @@ -0,0 +1,21 @@ +namespace CrestApps.Core.AI.Capabilities; + +/// +/// Applies the value selected for a registered model parameter to the outgoing chat request. +/// Modules register a binder for every parameter they contribute so runtime behavior stays +/// provider-agnostic and free of model name detection. +/// +public interface IAIModelParameterBinder +{ + /// + /// Gets the technical name of the parameter this binder applies. + /// + string ParameterName { get; } + + /// + /// Applies the selected value to the request represented by the given context. + /// + /// The binding context. + /// The token to monitor for cancellation requests. + Task BindAsync(AIModelParameterBindingContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/AICompletionContextExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/AICompletionContextExtensions.cs new file mode 100644 index 00000000..2807faf5 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Completions/AICompletionContextExtensions.cs @@ -0,0 +1,35 @@ +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Completions; + +/// +/// Provides extension methods for . +/// +public static class AICompletionContextExtensions +{ + /// + /// Copies the model parameter values held by the given metadata onto the completion context. + /// Empty values are ignored so a stored blank never overrides a deployment default. + /// + /// The completion context to populate. + /// The metadata holding the selected model parameter values. + public static void ApplyModelParameters(this AICompletionContext context, AIModelParametersMetadata metadata) + { + ArgumentNullException.ThrowIfNull(context); + + if (metadata?.Values is not { Count: > 0 }) + { + return; + } + + foreach (var (name, value) in metadata.Values) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + context.ModelParameters[name] = value; + } + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs index af316ff2..9a4f2827 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AICompletionContext.cs @@ -116,6 +116,13 @@ private string _utilityDeploymentIdBackingField set => UtilityDeploymentName = value; } + /// + /// Gets the model parameter values selected for this request, keyed by the registered + /// parameter technical name. Values for parameters that the resolved deployment does not + /// expose are discarded before the request is sent to the provider. + /// + public Dictionary ModelParameters { get; } = new(StringComparer.OrdinalIgnoreCase); + /// /// Gets the additional provider-specific properties applied to the completion request. /// diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCapabilities.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCapabilities.cs new file mode 100644 index 00000000..6e909f4b --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCapabilities.cs @@ -0,0 +1,79 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Represents the effective capabilities of an after the registered +/// definitions have been merged with the deployment specific metadata. +/// +public sealed class AIDeploymentCapabilities +{ + /// + /// Gets an instance that exposes no features and no parameters. + /// + public static AIDeploymentCapabilities Empty { get; } = new AIDeploymentCapabilities([], []); + + private readonly Dictionary _parameters; + private readonly HashSet _features; + + /// + /// Initializes a new instance of the class. + /// + /// The features exposed by the deployment. + /// The effective parameters exposed by the deployment. + public AIDeploymentCapabilities( + IEnumerable features, + IEnumerable parameters) + { + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(parameters); + + Features = [.. features.OrderBy(feature => feature.Order).ThenBy(feature => feature.Name, StringComparer.OrdinalIgnoreCase)]; + Parameters = [.. parameters.OrderBy(parameter => parameter.Order).ThenBy(parameter => parameter.Name, StringComparer.OrdinalIgnoreCase)]; + _features = new HashSet(Features.Select(feature => feature.Name), StringComparer.OrdinalIgnoreCase); + _parameters = Parameters.ToDictionary(parameter => parameter.Name, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the features exposed by the deployment. + /// + public IReadOnlyList Features { get; } + + /// + /// Gets the effective parameters exposed by the deployment. + /// + public IReadOnlyList Parameters { get; } + + /// + /// Determines whether the deployment exposes the given feature. + /// + /// The technical name of the feature. + public bool SupportsFeature(string featureName) + { + return !string.IsNullOrWhiteSpace(featureName) && _features.Contains(featureName); + } + + /// + /// Gets the effective descriptor of the given parameter, or when the + /// deployment does not expose it. + /// + /// The technical name of the parameter. + public AIModelParameterDescriptor GetParameter(string parameterName) + { + if (string.IsNullOrWhiteSpace(parameterName)) + { + return null; + } + + return _parameters.TryGetValue(parameterName, out var descriptor) + ? descriptor + : null; + } + + /// + /// Determines whether the deployment exposes the given parameter. + /// + /// The technical name of the parameter. + public bool SupportsParameter(string parameterName) + { + return GetParameter(parameterName) is not null; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelMetadata.cs new file mode 100644 index 00000000..92d99cbf --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelMetadata.cs @@ -0,0 +1,46 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Metadata stored on describing the features and configurable parameters +/// exposed by the underlying model. Editors, validation, and runtime request generation are driven from +/// this metadata instead of provider or model name detection. +/// +public sealed class AIDeploymentModelMetadata +{ + /// + /// Gets or sets the technical names of the registered model features supported by this deployment. + /// + public string[] Features { get; set; } = []; + + /// + /// Gets or sets the supported model parameters keyed by their registered technical name. + /// A parameter that is not present in this dictionary is not supported by the deployment and is + /// never rendered by editors or sent to the provider. + /// + public Dictionary Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Determines whether the deployment supports the given registered feature. + /// + /// The technical name of the feature. + public bool SupportsFeature(string featureName) + { + if (string.IsNullOrWhiteSpace(featureName) || Features is not { Length: > 0 }) + { + return false; + } + + return Features.Any(feature => string.Equals(feature, featureName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Determines whether the deployment supports the given registered parameter. + /// + /// The technical name of the parameter. + public bool SupportsParameter(string parameterName) + { + return !string.IsNullOrWhiteSpace(parameterName) && + Parameters is not null && + Parameters.ContainsKey(parameterName); + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelParameter.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelParameter.cs new file mode 100644 index 00000000..6b50eb68 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentModelParameter.cs @@ -0,0 +1,52 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Describes the per-deployment metadata of a supported model parameter. Every member is optional and, +/// when supplied, narrows the globally registered so a deployment +/// can describe the exact behavior of its underlying model. +/// +public sealed class AIDeploymentModelParameter +{ + /// + /// Gets or sets the subset of allowed values supported by this deployment. + /// When empty, the registered allowed values are used. + /// + public string[] AllowedValues { get; set; } + + /// + /// Gets or sets the value applied when an operator does not select one. + /// + public string DefaultValue { get; set; } + + /// + /// Gets or sets the inclusive minimum accepted value for numeric parameters. + /// + public double? Minimum { get; set; } + + /// + /// Gets or sets the inclusive maximum accepted value for numeric parameters. + /// + public double? Maximum { get; set; } + + /// + /// Gets or sets the increment applied by numeric editors. + /// + public double? Step { get; set; } + + /// + /// Creates a copy of this instance. + /// + public AIDeploymentModelParameter Clone() + { + return new AIDeploymentModelParameter + { + AllowedValues = AllowedValues is null + ? null + : [.. AllowedValues], + DefaultValue = DefaultValue, + Minimum = Minimum, + Maximum = Maximum, + Step = Step, + }; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelCapabilityOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelCapabilityOptions.cs new file mode 100644 index 00000000..cf7a6951 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelCapabilityOptions.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Models; + +/// +/// Holds the model features and model parameters that modules contribute to the framework. +/// Deployments reference these registered definitions through . +/// +public sealed class AIModelCapabilityOptions +{ + /// + /// Gets the registered model features keyed by their technical name. + /// + public IDictionary Features { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the registered model parameters keyed by their technical name. + /// + public IDictionary Parameters { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a model feature, or updates the definition when the feature is already registered. + /// + /// The technical name of the feature. + /// The display text shown to operators. + /// An optional delegate used to further configure the descriptor. + public AIModelCapabilityOptions AddFeature(string name, LocalizedString displayName, Action configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + if (!Features.TryGetValue(name, out var descriptor)) + { + descriptor = new AIModelFeatureDescriptor(); + Features[name] = descriptor; + } + + descriptor.Name = name; + + if (displayName is not null) + { + descriptor.DisplayName = displayName; + } + + descriptor.DisplayName ??= new LocalizedString(name, name); + configure?.Invoke(descriptor); + + return this; + } + + /// + /// Adds a model parameter, or updates the definition when the parameter is already registered. + /// + /// The technical name of the parameter. + /// The display text shown to operators. + /// An optional delegate used to further configure the descriptor. + public AIModelCapabilityOptions AddParameter(string name, LocalizedString displayName, Action configure = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + if (!Parameters.TryGetValue(name, out var descriptor)) + { + descriptor = new AIModelParameterDescriptor(); + Parameters[name] = descriptor; + } + + descriptor.Name = name; + + if (displayName is not null) + { + descriptor.DisplayName = displayName; + } + + descriptor.DisplayName ??= new LocalizedString(name, name); + configure?.Invoke(descriptor); + + return this; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs new file mode 100644 index 00000000..76e3a6ff --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Models; + +/// +/// Describes a binary capability that a model behind an can perform, +/// such as tool calling, structured outputs, or audio input. +/// +/// +/// Features are registered by modules through so that +/// providers can contribute new capabilities without changing the core framework. +/// Unlike , which drives deployment routing, features describe +/// what the underlying model is able to do. +/// +public sealed class AIModelFeatureDescriptor +{ + /// + /// Gets or sets the technical name that uniquely identifies the feature. + /// + public string Name { get; set; } + + /// + /// Gets or sets the display text shown to operators. + /// + public LocalizedString DisplayName { get; set; } + + /// + /// Gets or sets the descriptive text shown to operators. + /// + public LocalizedString Description { get; set; } + + /// + /// Gets or sets the optional grouping category used to organize features in editors. + /// + public string Category { get; set; } + + /// + /// Gets or sets the sort order used when features are listed. Lower values are listed first. + /// + public int Order { get; set; } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs new file mode 100644 index 00000000..1e6f17e7 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs @@ -0,0 +1,48 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Well-known technical names of the model features registered by the framework. +/// Modules can register additional features using . +/// +public static class AIModelFeatureNames +{ + /// + /// The model can call tools or functions supplied with the request. + /// + public const string ToolCalling = "toolCalling"; + + /// + /// The model can return responses that conform to a supplied JSON schema. + /// + public const string StructuredOutputs = "structuredOutputs"; + + /// + /// The model can stream response updates as they are produced. + /// + public const string Streaming = "streaming"; + + /// + /// The model performs internal reasoning before producing an answer. + /// + public const string Reasoning = "reasoning"; + + /// + /// The model accepts audio input. + /// + public const string AudioInput = "audioInput"; + + /// + /// The model produces audio output. + /// + public const string AudioOutput = "audioOutput"; + + /// + /// The model can operate a computer or browser environment. + /// + public const string ComputerUse = "computerUse"; + + /// + /// The model can search the web as part of producing a response. + /// + public const string WebSearch = "webSearch"; +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs new file mode 100644 index 00000000..939f9cb2 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs @@ -0,0 +1,135 @@ +using System.Globalization; +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Models; + +/// +/// Describes a configurable model parameter, such as reasoning effort or temperature, along with the +/// metadata required to render an editor for it and to validate the value supplied by an operator. +/// +/// +/// Parameters are registered by modules through . An +/// declares which registered parameters its underlying model exposes and +/// may narrow the registered metadata through . +/// +public sealed class AIModelParameterDescriptor +{ + /// + /// Gets or sets the technical name that uniquely identifies the parameter. + /// + public string Name { get; set; } + + /// + /// Gets or sets the display text shown to operators. + /// + public LocalizedString DisplayName { get; set; } + + /// + /// Gets or sets the descriptive text shown to operators. + /// + public LocalizedString Description { get; set; } + + /// + /// Gets or sets the editor and validation semantics of the parameter. + /// + public AIModelParameterKind Kind { get; set; } + + /// + /// Gets or sets the allowed values when is . + /// + public IList AllowedValues { get; set; } = []; + + /// + /// Gets or sets the inclusive minimum accepted value for numeric parameters. + /// + public double? Minimum { get; set; } + + /// + /// Gets or sets the inclusive maximum accepted value for numeric parameters. + /// + public double? Maximum { get; set; } + + /// + /// Gets or sets the increment applied by numeric editors. + /// + public double? Step { get; set; } + + /// + /// Gets or sets the value applied when an operator does not select one. + /// + public string DefaultValue { get; set; } + + /// + /// Gets or sets the optional grouping category used to organize parameters in editors. + /// + public string Category { get; set; } + + /// + /// Gets or sets the sort order used when parameters are listed. Lower values are listed first. + /// + public int Order { get; set; } + + /// + /// Creates a copy of the descriptor so per-deployment metadata can be applied without + /// mutating the globally registered definition. + /// + public AIModelParameterDescriptor Clone() + { + return new AIModelParameterDescriptor + { + Name = Name, + DisplayName = DisplayName, + Description = Description, + Kind = Kind, + AllowedValues = AllowedValues is null + ? [] + : [.. AllowedValues], + Minimum = Minimum, + Maximum = Maximum, + Step = Step, + DefaultValue = DefaultValue, + Category = Category, + Order = Order, + }; + } + + /// + /// Determines whether the given value is valid for this parameter. + /// + /// The value to validate. A or empty value is always considered valid. + public bool IsValidValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + switch (Kind) + { + case AIModelParameterKind.Choice: + return AllowedValues is not { Count: > 0 } || + AllowedValues.Any(option => string.Equals(option.Value, value, StringComparison.OrdinalIgnoreCase)); + + case AIModelParameterKind.Boolean: + return bool.TryParse(value, out _); + + case AIModelParameterKind.Integer: + case AIModelParameterKind.Number: + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + { + return false; + } + + if (Kind == AIModelParameterKind.Integer && number != Math.Truncate(number)) + { + return false; + } + + return (!Minimum.HasValue || number >= Minimum.Value) && + (!Maximum.HasValue || number <= Maximum.Value); + + default: + return true; + } + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterKind.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterKind.cs new file mode 100644 index 00000000..ca9a6630 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterKind.cs @@ -0,0 +1,33 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Describes the editor and validation semantics of an . +/// The value drives how a parameter is rendered and validated by metadata-driven editors. +/// +public enum AIModelParameterKind +{ + /// + /// The parameter accepts one value from a closed list of allowed values and renders as a dropdown. + /// + Choice = 0, + + /// + /// The parameter accepts a floating point number within an optional range and renders as a numeric input. + /// + Number = 1, + + /// + /// The parameter accepts a whole number within an optional range and renders as a numeric input. + /// + Integer = 2, + + /// + /// The parameter accepts a or value and renders as a checkbox. + /// + Boolean = 3, + + /// + /// The parameter accepts free-form text and renders as a text input. + /// + Text = 4, +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterNames.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterNames.cs new file mode 100644 index 00000000..e3202be3 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterNames.cs @@ -0,0 +1,23 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Well-known technical names of the model parameters registered by the framework. +/// Modules can register additional parameters using . +/// +public static class AIModelParameterNames +{ + /// + /// Controls how much internal reasoning the model applies before answering. + /// + public const string ReasoningEffort = "reasoningEffort"; + + /// + /// Controls how verbose the produced answer should be. + /// + public const string Verbosity = "verbosity"; + + /// + /// The deterministic sampling seed used to make responses reproducible. + /// + public const string Seed = "seed"; +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterOption.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterOption.cs new file mode 100644 index 00000000..e2b3d970 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterOption.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.Core.AI.Models; + +/// +/// Represents a single allowed value of an whose +/// is . +/// +public sealed class AIModelParameterOption +{ + /// + /// Gets or sets the technical value persisted on the model and sent to the provider. + /// + public string Value { get; set; } + + /// + /// Gets or sets the display text shown to operators. + /// + public LocalizedString DisplayName { get; set; } + + /// + /// Gets or sets the optional descriptive text shown to operators. + /// + public LocalizedString Description { get; set; } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParametersMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParametersMetadata.cs new file mode 100644 index 00000000..80e58df5 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParametersMetadata.cs @@ -0,0 +1,30 @@ +namespace CrestApps.Core.AI.Models; + +/// +/// Metadata stored on an AI profile, profile template, or chat interaction that holds the model +/// parameter values selected by an operator. Values are keyed by the registered parameter technical +/// name so new parameters do not require model or storage changes. +/// +public sealed class AIModelParametersMetadata +{ + /// + /// Gets or sets the selected parameter values keyed by their registered technical name. + /// + public Dictionary Values { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the value selected for the given parameter, or when none was selected. + /// + /// The technical name of the parameter. + public string GetValue(string parameterName) + { + if (string.IsNullOrWhiteSpace(parameterName) || Values is null) + { + return null; + } + + return Values.TryGetValue(parameterName, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs index 40aeac0c..3418f573 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/CompletionServiceConfigureContext.cs @@ -19,6 +19,11 @@ public sealed class CompletionServiceConfigureContext /// public string DeploymentName { get; set; } + /// + /// Gets or sets the deployment resolved for this request. + /// + public AIDeployment Deployment { get; set; } + /// /// Gets or sets a value indicating whether the completion will be streamed. /// diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 4c07d9c9..74fc022d 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -18,3 +18,43 @@ page will be updated as changes land after 1.0.0. ## Highlights - upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages + +## Metadata-driven model features and parameters + +AI deployments can now describe what their model supports instead of relying on hardcoded, +provider-specific options. See [Model Capabilities](../core/ai-model-capabilities.md) for the full +guide. + +- adds a startup registry of **model features** (binary capabilities such as `toolCalling`, + `reasoning`, and `streaming`) and **model parameters** (configurable options carrying kind, allowed + values, ranges, and defaults), registered through the new `AddAIModelFeature` and + `AddAIModelParameter` service-collection extensions +- adds `AddCoreAIModelCapabilities()`, chained automatically by `AddCoreAIServices()`, which registers + the capability service, the completion handler, the reasoning-effort binder, eight built-in + features, and the built-in `reasoningEffort` parameter +- adds `AIDeploymentModelMetadata` so a deployment declares the features and parameters its model + exposes and can narrow the allowed values, default, or numeric bounds of a registered parameter. + The metadata flows through configuration and recipes with no additional code because deployment + properties are already deep-merged +- adds `IAIModelCapabilityService`, which merges the registered definitions with the deployment + metadata and returns only the capabilities a deployment actually exposes +- adds `AIModelParametersMetadata` so AI profiles, AI profile templates, and chat interactions store + the selected parameter values, and adds `AICompletionContext.ModelParameters` plus + `ApplyModelParameters` to carry them into a request +- adds `ModelParametersAICompletionServiceHandler`, the single runtime enforcement point that ignores + values for parameters the deployment does not expose, falls back to the deployment default when a + stored value is missing or invalid, dispatches to a matching `IAIModelParameterBinder`, and + otherwise writes the value to `ChatOptions.AdditionalProperties` +- adds `IAIModelParameterBinder` with a built-in reasoning-effort binder that sets + `ChatOptions.Reasoning.Effort`, and updates `AzureOpenAICompletionClient` to translate the resolved + effort onto `ChatCompletionOptions.ReasoningEffortLevel` so both request paths behave the same +- adds a `ModelParameters` front-matter key to the markdown profile template parser, accepting + `name=value` pairs separated by `;` or by a new line +- adds `CompletionServiceConfigureContext.Deployment` so handlers can resolve the deployment that is + being configured. `DeploymentName` continues to carry the model name +- updates `CrestApps.Core.Mvc.Web` with a reusable deployment capability editor and a metadata-driven + model parameter editor wired into the AI deployment, AI profile, AI profile template, and chat + interaction screens. Unsupported fields are hidden and disabled so they are never submitted +- updates `CrestApps.Core.Blazor.Web` with the equivalent `ModelCapabilitiesEditor` and + `ModelParametersEditor` components, which re-render when the selected deployment changes and prune + values the new deployment does not support diff --git a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md new file mode 100644 index 00000000..47f439d6 --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md @@ -0,0 +1,323 @@ +--- +sidebar_label: Model Capabilities +sidebar_position: 16 +title: AI Model Capabilities +description: Metadata-driven model features and model parameters that describe what an AI deployment supports and which options its consumers may configure. +--- + +# AI Model Capabilities + +> A registry of **model features** and **model parameters** that lets deployments declare what their +> model supports, so editors render only the relevant options and the runtime only sends supported values. + +## Why this exists + +Different models expose different knobs. A reasoning model accepts a reasoning effort level, a small +chat model does not. Without metadata, every new knob turns into hardcoded, provider-specific UI and +provider-specific request-building code. + +The capability system replaces that with two extensible concepts: + +| Concept | Shape | Example | +| --- | --- | --- | +| **Model feature** | A binary capability. The deployment either supports it or it does not. | `toolCalling`, `reasoning`, `streaming` | +| **Model parameter** | A configurable option carrying metadata: kind, allowed values, range, and a default. | `reasoningEffort` | + +Definitions are registered once at startup. An **AI Deployment** then declares which of those +definitions its model actually exposes, optionally narrowing the allowed values. AI Profiles, AI +Profile Templates, and Chat Interactions store the selected values. At request time the framework +binds the selected values into the outgoing request. + +:::info +Model features are **not** the same as `AIDeploymentPurpose`. `Purpose` (`Chat`, `Utility`, +`Embedding`, `Image`, …) drives *routing* — which deployment is picked for a given job. Features +describe *capabilities within* a deployment and deliberately avoid duplicating routing concerns. +::: + +## Quick Start + +```csharp +builder.Services.AddCoreAIModelCapabilities(); +``` + +:::info +You rarely need to call this directly — `AddCoreAIServices()` chains it automatically. +::: + +## Built-in definitions + +### Features + +| Name | Constant | Description | +| --- | --- | --- | +| `toolCalling` | `AIModelFeatureNames.ToolCalling` | The model can call tools and functions supplied with the request. | +| `structuredOutputs` | `AIModelFeatureNames.StructuredOutputs` | The model can return responses that follow a supplied JSON schema. | +| `streaming` | `AIModelFeatureNames.Streaming` | The model can stream response updates as they are produced. | +| `reasoning` | `AIModelFeatureNames.Reasoning` | The model performs internal reasoning before producing an answer. | +| `audioInput` | `AIModelFeatureNames.AudioInput` | The model accepts audio input. | +| `audioOutput` | `AIModelFeatureNames.AudioOutput` | The model produces audio output. | +| `webSearch` | `AIModelFeatureNames.WebSearch` | The model can search the web while producing a response. | +| `computerUse` | `AIModelFeatureNames.ComputerUse` | The model can operate a computer or browser environment. | + +### Parameters + +| Name | Constant | Kind | Allowed values | Default | +| --- | --- | --- | --- | --- | +| `reasoningEffort` | `AIModelParameterNames.ReasoningEffort` | `Choice` | `None` (shown as *Minimal*), `Low`, `Medium`, `High`, `ExtraHigh` | `Medium` | + +`reasoningEffort` maps onto `Microsoft.Extensions.AI.ChatOptions.Reasoning.Effort`, so it is +provider-agnostic and ships in the core AI package rather than in a provider module. + +## Declaring what a deployment supports + +Capability metadata is stored on the deployment through the +[extensible entity](./extensible-entity.md) `Properties` bag using `AIDeploymentModelMetadata`: + +```csharp +deployment.Put(new AIDeploymentModelMetadata +{ + Features = + [ + AIModelFeatureNames.ToolCalling, + AIModelFeatureNames.Reasoning, + ], + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AIModelParameterNames.ReasoningEffort] = new AIDeploymentModelParameter + { + AllowedValues = ["Low", "Medium", "High"], + DefaultValue = "Medium", + }, + }, +}); +``` + +Because `AIDeploymentCatalogHandler` deep-merges `Properties`, the same metadata can be supplied from +configuration or a recipe with no extra code: + +```json +{ + "CrestApps": { + "AI": { + "Deployments": [ + { + "Name": "gpt-5-chat", + "ModelName": "gpt-5", + "ConnectionName": "openai", + "Properties": { + "AIDeploymentModelMetadata": { + "Features": [ "toolCalling", "reasoning", "streaming" ], + "Parameters": { + "reasoningEffort": { + "AllowedValues": [ "Low", "Medium", "High" ], + "DefaultValue": "Medium" + } + } + } + } + } + ] + } + } +} +``` + +A deployment-level parameter entry may narrow or override the registered definition: + +| Property | Effect | +| --- | --- | +| `AllowedValues` | Restricts a `Choice` parameter to a subset of the registered options. | +| `DefaultValue` | Overrides the registered default. Ignored when the value is not valid for the parameter. | +| `Minimum`, `Maximum`, `Step` | Overrides the numeric bounds for `Number` and `Integer` parameters. | + +## Resolving capabilities + +`IAIModelCapabilityService` merges the registered definitions with the deployment metadata and returns +only what the deployment exposes: + +```csharp +public sealed class MyService +{ + private readonly IAIModelCapabilityService _capabilityService; + + public MyService(IAIModelCapabilityService capabilityService) + { + _capabilityService = capabilityService; + } + + public async Task SupportsReasoningAsync(string deploymentName) + { + var capabilities = await _capabilityService.GetCapabilitiesAsync(deploymentName); + + return capabilities.SupportsFeature(AIModelFeatureNames.Reasoning); + } +} +``` + +| Member | Description | +| --- | --- | +| `GetRegisteredFeatures()` | Every registered feature descriptor, ordered. | +| `GetRegisteredParameters()` | Every registered parameter descriptor, ordered. | +| `GetCapabilities(AIDeployment)` | Resolves capabilities from an already loaded deployment. | +| `GetCapabilitiesAsync(string, CancellationToken)` | Loads the deployment by name and resolves its capabilities. | + +The returned `AIDeploymentCapabilities` exposes `Features`, `Parameters`, `SupportsFeature`, +`SupportsParameter`, and `GetParameter`. Descriptors are cloned, so the registered definitions are +never mutated by a deployment override. + +## Storing selected values + +Consumers store their selections with `AIModelParametersMetadata`, again through the extensible +entity `Properties` bag: + +```csharp +profile.Put(new AIModelParametersMetadata +{ + Values = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AIModelParameterNames.ReasoningEffort] = "High", + }, +}); +``` + +This works on `AIProfile`, `AIProfileTemplate`, and `ChatInteraction`. Profile and chat interaction +context builder handlers copy the stored values onto `AICompletionContext.ModelParameters` before the +request is built. + +### Markdown profile templates + +Markdown-authored profile templates can set values through the `ModelParameters` front-matter key. +Pairs are `name=value`, separated by `;` or by a new line: + +```markdown +--- +Name: Deep research +ModelParameters: reasoningEffort=High +--- + +You are a meticulous research assistant. +``` + +## Runtime binding + +`ModelParametersAICompletionServiceHandler` runs as an `IAICompletionServiceHandler` and is the single +enforcement point: + +1. It iterates only the parameters the **deployment** exposes. A value stored for an unsupported + parameter is ignored and never leaves the process. +2. When the stored value is missing or invalid for the resolved descriptor, the deployment default is + used and a warning is logged. +3. If an `IAIModelParameterBinder` is registered for the parameter, the binder shapes the request. +4. Otherwise the value is written to `ChatOptions.AdditionalProperties` so providers that read raw + properties still receive it. + +`ReasoningEffortModelParameterBinder` implements step 3 for `reasoningEffort` by setting +`ChatOptions.Reasoning.Effort`. + +:::note +Azure OpenAI builds `OpenAI.Chat.ChatCompletionOptions` directly instead of going through +`Microsoft.Extensions.AI.ChatOptions`. `AzureOpenAICompletionClient` therefore translates the resolved +reasoning effort onto `ChatCompletionOptions.ReasoningEffortLevel` as well, so both request paths +behave the same. +::: + +## Registering your own definitions + +Any module can contribute definitions during startup. + +```csharp +services.AddAIModelFeature( + "imageInput", + new LocalizedString("imageInput", "Image input"), + feature => + { + feature.Description = new LocalizedString("imageInput", "The model accepts image input."); + feature.Order = 90; + }); + +services.AddAIModelParameter( + "verbosity", + new LocalizedString("verbosity", "Verbosity"), + parameter => + { + parameter.Kind = AIModelParameterKind.Choice; + parameter.DefaultValue = "medium"; + parameter.AllowedValues = + [ + new AIModelParameterOption { Value = "low", DisplayName = new LocalizedString("low", "Low") }, + new AIModelParameterOption { Value = "medium", DisplayName = new LocalizedString("medium", "Medium") }, + new AIModelParameterOption { Value = "high", DisplayName = new LocalizedString("high", "High") }, + ]; + }); +``` + +Registering the same name again updates the existing descriptor instead of adding a duplicate, so a +provider module can refine a definition contributed by another module. + +### Parameter kinds + +| Kind | Editor | Notes | +| --- | --- | --- | +| `Choice` | Drop-down | Requires `AllowedValues`. | +| `Number` | Numeric input | Honors `Minimum`, `Maximum`, and `Step`. | +| `Integer` | Numeric input | Honors `Minimum`, `Maximum`, and `Step`. | +| `Boolean` | Drop-down of `true` / `false` | | +| `Text` | Free-text input | | + +### Custom binders + +Implement `IAIModelParameterBinder` when a parameter needs to shape the request beyond +`AdditionalProperties`: + +```csharp +public sealed class VerbosityModelParameterBinder : IAIModelParameterBinder +{ + public string ParameterName => "verbosity"; + + public ValueTask BindAsync(AIModelParameterBindingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + context.ChatOptions.AdditionalProperties ??= []; + context.ChatOptions.AdditionalProperties["verbosity"] = context.Value; + + return ValueTask.CompletedTask; + } +} +``` + +```csharp +services.AddScoped(); +``` + +The binding context exposes the resolved `Descriptor`, the selected `Value`, the `ChatOptions` being +built, the `CompletionContext`, and the `Deployment`. + +## Sample host editors + +Both sample hosts render the metadata rather than hardcoding options. + +- **AI Deployment editor** — lists every registered feature as a checkbox and every registered + parameter with a *supported* toggle, an allowed-values selector, a default value, and numeric bounds + where applicable. +- **AI Profile, AI Profile Template, and Chat Interaction editors** — render only the parameters the + selected deployment supports, restricted to that deployment's allowed values. + +In `CrestApps.Core.Mvc.Web` the server renders every registered parameter inside hidden, disabled +wrappers together with a deployment-to-capability JSON map; a small script shows, enables, and filters +the fields when the deployment selection changes. Disabled inputs are not posted, so an unsupported +value can never be submitted. In `CrestApps.Core.Blazor.Web` the components re-render reactively and +prune values that the newly selected deployment does not support. + +:::note +The GitHub Copilot and Claude orchestrators keep their own effort settings +(`CopilotReasoningEffort`, `ClaudeEffortLevel`). Those are orchestrator/session-level options rather +than deployment-level model parameters and are intentionally left unchanged. +::: + +## Related + +- [AI Core](./ai-core.md) +- [AI Profiles](./ai-profiles.md) +- [AI Templates](./ai-templates.md) +- [Extensible Entity](./extensible-entity.md) diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index c9e45181..bbff9c91 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -38,6 +38,7 @@ const sidebars = { 'core/ai-core', 'core/ai-documents', 'core/ai-memory', + 'core/ai-model-capabilities', 'core/ai-templates', 'core/chat', 'core/context-builders', diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs index c8ba7665..50aedf45 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs @@ -42,6 +42,12 @@ public async Task BuildingAsync(AICompletionContextBuildingContext context) context.Context.A2AConnectionIds = interaction.A2AConnectionIds?.ToArray(); context.Context.AdditionalProperties[AICompletionContextKeys.Interaction] = interaction; context.Context.AdditionalProperties[AICompletionContextKeys.InteractionId] = interaction.ItemId; + + if (interaction.TryGet(out var modelParameters)) + { + context.Context.ApplyModelParameters(modelParameters); + } + if (interaction.TryGet(out var dataSourceMetadata) && !string.IsNullOrEmpty(dataSourceMetadata.DataSourceId)) { context.Context.DataSourceId = dataSourceMetadata.DataSourceId; diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs index 2d85f1bd..f6a0d36e 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs @@ -113,8 +113,9 @@ public string ClientName var connectionName = deployment.ConnectionName; var azureClient = GetChatClient(connectionProperties); var chatClient = azureClient.GetChatClient(deployment.ModelName); - var functions = await ResolveToolsAsync(context, deployment.ModelName); - var chatOptions = GetOptions(context, functions); + var requestOptions = await ResolveRequestOptionsAsync(context, deployment, isStreaming: false); + var functions = requestOptions.Functions; + var chatOptions = GetOptions(context, functions, requestOptions.ResolvedOptions); var systemFunctions = await ConfigureOptionsAsync(chatOptions, context, prompts); var allFunctions = systemFunctions.Count > 0 ? functions.Concat(systemFunctions) : functions; try @@ -131,7 +132,7 @@ public string ClientName { await ProcessToolCallsAsync(prompts, data.Value.ToolCalls, allFunctions); // Create a new chat option that excludes references to data sources to address the limitations in Azure OpenAI. - data = await chatClient.CompleteChatAsync(prompts, GetOptions(context, allFunctions), cancellationToken); + data = await chatClient.CompleteChatAsync(prompts, GetOptions(context, allFunctions, requestOptions.ResolvedOptions), cancellationToken); iterations++; } @@ -203,8 +204,9 @@ public string ClientName var connectionName = deployment.ConnectionName; var azureClient = GetChatClient(connection); var chatClient = azureClient.GetChatClient(deployment.ModelName); - var functions = await ResolveToolsAsync(context, deployment.ModelName); - var chatOptions = GetOptions(context, functions); + var requestOptions = await ResolveRequestOptionsAsync(context, deployment, isStreaming: true); + var functions = requestOptions.Functions; + var chatOptions = GetOptions(context, functions, requestOptions.ResolvedOptions); ChatCompletionOptions subSequenceContext = null; var prompts = GetPrompts(context, azureMessages); var systemFunctions = await ConfigureOptionsAsync(chatOptions, context, prompts); @@ -235,7 +237,7 @@ public string ClientName // Clear accumulated tool calls for the next iteration. accumulatedToolCalls.Clear(); // Create a new chat option that excludes references to data sources to address the limitations in Azure OpenAI. - chatOptions = subSequenceContext ??= GetOptions(context, allFunctions); + chatOptions = subSequenceContext ??= GetOptions(context, allFunctions, requestOptions.ResolvedOptions); hasToolCalls = true; iterations++; break; @@ -443,7 +445,7 @@ private AzureOpenAIClient GetChatClient(AIProviderConnectionEntry connection) return optionsContext.SystemFunctions; } - private static ChatCompletionOptions GetOptions(AICompletionContext context, IEnumerable functions) + private static ChatCompletionOptions GetOptions(AICompletionContext context, IEnumerable functions, Microsoft.Extensions.AI.ChatOptions resolvedOptions = null) { var chatOptions = new ChatCompletionOptions() { @@ -454,6 +456,8 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn MaxOutputTokenCount = context.MaxTokens, }; + ApplyReasoningEffort(chatOptions, resolvedOptions); + if (!context.DisableTools) { foreach (var function in functions) @@ -470,21 +474,37 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn return chatOptions; } - private async Task> ResolveToolsAsync(AICompletionContext context, string deploymentName) +#pragma warning disable OPENAI001 // ChatCompletionOptions.ReasoningEffortLevel is an evaluation-only API in the OpenAI SDK. + private static void ApplyReasoningEffort(ChatCompletionOptions chatOptions, Microsoft.Extensions.AI.ChatOptions resolvedOptions) { - if (context.DisableTools) + var effort = resolvedOptions?.Reasoning?.Effort; + + if (!effort.HasValue) { - return []; + return; } - // Use the same handler pipeline as NamedAICompletionClient to resolve tools. - // This ensures authorization checks and consistent tool resolution across all clients. + chatOptions.ReasoningEffortLevel = effort.Value switch + { + Microsoft.Extensions.AI.ReasoningEffort.None => ChatReasoningEffortLevel.Minimal, + Microsoft.Extensions.AI.ReasoningEffort.Low => ChatReasoningEffortLevel.Low, + Microsoft.Extensions.AI.ReasoningEffort.Medium => ChatReasoningEffortLevel.Medium, + _ => ChatReasoningEffortLevel.High, + }; + } +#pragma warning restore OPENAI001 + + private async Task ResolveRequestOptionsAsync(AICompletionContext context, AIDeployment deployment, bool isStreaming) + { + // Use the same handler pipeline as NamedAICompletionClient to resolve tools and model + // parameters. This ensures authorization checks and consistent behavior across all clients. var chatOptions = new Microsoft.Extensions.AI.ChatOptions(); - var configureContext = new CompletionServiceConfigureContext(chatOptions, context, isFunctionInvocationSupported: true) + var configureContext = new CompletionServiceConfigureContext(chatOptions, context, isFunctionInvocationSupported: !context.DisableTools) { - DeploymentName = deploymentName, + DeploymentName = deployment.ModelName, + Deployment = deployment, ClientName = ClientName, - IsStreaming = false, + IsStreaming = isStreaming, }; foreach (var handler in _completionServiceHandlers) @@ -492,14 +512,17 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn await handler.ConfigureAsync(configureContext); } - if (chatOptions.Tools is null || chatOptions.Tools.Count == 0) - { - return []; - } + var functions = context.DisableTools || chatOptions.Tools is not { Count: > 0 } + ? [] + : chatOptions.Tools.OfType().ToList(); - return chatOptions.Tools.OfType().ToList(); + return new AzureRequestOptions(functions, chatOptions); } + private sealed record AzureRequestOptions( + IReadOnlyList Functions, + Microsoft.Extensions.AI.ChatOptions ResolvedOptions); + private static List GetPrompts(AICompletionContext context, List azureMessages) { var prompts = new List(); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs index 7069343f..c502275a 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs @@ -49,6 +49,11 @@ public async Task BuildingAsync(AICompletionContextBuildingContext context) context.Context.UseCaching = metadata.UseCaching; } + if (profile.TryGet(out var modelParameters)) + { + context.Context.ApplyModelParameters(modelParameters); + } + if (profile.TryGet(out var functionInvocationMetadata)) { context.Context.ToolNames = functionInvocationMetadata.Names; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs new file mode 100644 index 00000000..45e5763d --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs @@ -0,0 +1,94 @@ +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Handlers; + +/// +/// Applies the model parameters selected for the current request to the outgoing +/// . Only the parameters exposed by the resolved deployment are applied, +/// which guarantees that unsupported parameters are never sent to a provider. +/// +public sealed class ModelParametersAICompletionServiceHandler : IAICompletionServiceHandler +{ + private readonly IAIModelCapabilityService _capabilityService; + private readonly IEnumerable _binders; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The capability service used to resolve deployment metadata. + /// The registered parameter binders. + /// The logger. + public ModelParametersAICompletionServiceHandler( + IAIModelCapabilityService capabilityService, + IEnumerable binders, + ILogger logger) + { + _capabilityService = capabilityService; + _binders = binders; + _logger = logger; + } + + /// + public async Task ConfigureAsync(CompletionServiceConfigureContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + var capabilities = context.Deployment is not null + ? _capabilityService.GetCapabilities(context.Deployment) + : await _capabilityService.GetCapabilitiesAsync(context.CompletionContext.ChatDeploymentName, cancellationToken); + + if (capabilities.Parameters.Count == 0) + { + return; + } + + foreach (var descriptor in capabilities.Parameters) + { + var value = GetValue(context.CompletionContext, descriptor); + + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + var bindingContext = new AIModelParameterBindingContext(descriptor, value, context.ChatOptions, context.CompletionContext) + { + Deployment = context.Deployment, + }; + + var binder = _binders.FirstOrDefault(candidate => string.Equals(candidate.ParameterName, descriptor.Name, StringComparison.OrdinalIgnoreCase)); + + if (binder is null) + { + context.ChatOptions.AdditionalProperties ??= []; + context.ChatOptions.AdditionalProperties[descriptor.Name] = value; + + continue; + } + + await binder.BindAsync(bindingContext, cancellationToken); + } + } + + private string GetValue(AICompletionContext completionContext, AIModelParameterDescriptor descriptor) + { + if (!completionContext.ModelParameters.TryGetValue(descriptor.Name, out var value) || string.IsNullOrWhiteSpace(value)) + { + return descriptor.DefaultValue; + } + + if (!descriptor.IsValidValue(value)) + { + _logger.LogWarning("The value '{Value}' is not valid for the model parameter '{Parameter}'. The deployment default is used instead.", value, descriptor.Name); + + return descriptor.DefaultValue; + } + + return value; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 4ba53e01..91f7c2c6 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.AI.Capabilities; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Completions; @@ -191,9 +192,144 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services.TryAddEnumerable(ServiceDescriptor.Scoped, AIDeploymentCatalogHandler>()); services.TryAddEnumerable(ServiceDescriptor.Scoped, AIProviderConnectionCatalogHandler>()); + services.AddCoreAIModelCapabilities(); + + return services; + } + + /// + /// Adds the metadata-driven model capability services along with the model features and + /// model parameters that ship with the framework. + /// + /// The service collection. + public static IServiceCollection AddCoreAIModelCapabilities(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddOptions(); + services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + services + .AddAIModelFeature(AIModelFeatureNames.ToolCalling, new LocalizedString(AIModelFeatureNames.ToolCalling, "Tool calling"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.ToolCalling, "The model can call tools and functions supplied with the request."); + feature.Order = 10; + }) + .AddAIModelFeature(AIModelFeatureNames.StructuredOutputs, new LocalizedString(AIModelFeatureNames.StructuredOutputs, "Structured outputs"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.StructuredOutputs, "The model can return responses that follow a supplied JSON schema."); + feature.Order = 20; + }) + .AddAIModelFeature(AIModelFeatureNames.Streaming, new LocalizedString(AIModelFeatureNames.Streaming, "Streaming"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.Streaming, "The model can stream response updates as they are produced."); + feature.Order = 30; + }) + .AddAIModelFeature(AIModelFeatureNames.Reasoning, new LocalizedString(AIModelFeatureNames.Reasoning, "Reasoning"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.Reasoning, "The model performs internal reasoning before producing an answer."); + feature.Order = 40; + }) + .AddAIModelFeature(AIModelFeatureNames.AudioInput, new LocalizedString(AIModelFeatureNames.AudioInput, "Audio input"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.AudioInput, "The model accepts audio input."); + feature.Order = 50; + }) + .AddAIModelFeature(AIModelFeatureNames.AudioOutput, new LocalizedString(AIModelFeatureNames.AudioOutput, "Audio output"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.AudioOutput, "The model produces audio output."); + feature.Order = 60; + }) + .AddAIModelFeature(AIModelFeatureNames.WebSearch, new LocalizedString(AIModelFeatureNames.WebSearch, "Web search"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.WebSearch, "The model can search the web while producing a response."); + feature.Order = 70; + }) + .AddAIModelFeature(AIModelFeatureNames.ComputerUse, new LocalizedString(AIModelFeatureNames.ComputerUse, "Computer use"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.ComputerUse, "The model can operate a computer or browser environment."); + feature.Order = 80; + }); + + services.AddAIModelParameter(AIModelParameterNames.ReasoningEffort, new LocalizedString(AIModelParameterNames.ReasoningEffort, "Reasoning effort"), parameter => + { + parameter.Description = new LocalizedString(AIModelParameterNames.ReasoningEffort, "Controls how much internal reasoning the model applies before answering. Higher values produce more thoughtful answers with increased latency and cost."); + parameter.Kind = AIModelParameterKind.Choice; + parameter.DefaultValue = nameof(ReasoningEffort.Medium); + parameter.Order = 10; + parameter.AllowedValues = + [ + new AIModelParameterOption + { + Value = nameof(ReasoningEffort.None), + DisplayName = new LocalizedString(nameof(ReasoningEffort.None), "Minimal"), + }, + new AIModelParameterOption + { + Value = nameof(ReasoningEffort.Low), + DisplayName = new LocalizedString(nameof(ReasoningEffort.Low), "Low"), + }, + new AIModelParameterOption + { + Value = nameof(ReasoningEffort.Medium), + DisplayName = new LocalizedString(nameof(ReasoningEffort.Medium), "Medium"), + }, + new AIModelParameterOption + { + Value = nameof(ReasoningEffort.High), + DisplayName = new LocalizedString(nameof(ReasoningEffort.High), "High"), + }, + new AIModelParameterOption + { + Value = nameof(ReasoningEffort.ExtraHigh), + DisplayName = new LocalizedString(nameof(ReasoningEffort.ExtraHigh), "Extra high"), + }, + ]; + }); + return services; } + /// + /// Registers a model feature that deployments can declare support for. + /// + /// The service collection. + /// The technical name of the feature. + /// The display text shown to operators. + /// An optional delegate used to further configure the descriptor. + public static IServiceCollection AddAIModelFeature( + this IServiceCollection services, + string name, + LocalizedString displayName, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return services.Configure(options => options.AddFeature(name, displayName, configure)); + } + + /// + /// Registers a model parameter that deployments can declare support for. + /// + /// The service collection. + /// The technical name of the parameter. + /// The display text shown to operators. + /// An optional delegate used to further configure the descriptor. + public static IServiceCollection AddAIModelParameter( + this IServiceCollection services, + string name, + LocalizedString displayName, + Action configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return services.Configure(options => options.AddParameter(name, displayName, configure)); + } + /// /// Adds ai suite. /// diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs index f513a710..a5a14698 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs @@ -165,11 +165,50 @@ public static AIProfileTemplate Parse(string id, TemplateParseResult parseResult profileMetadata.AllowToolInvocation = allowToolInvocation; } + var modelParameters = ParseModelParameters(props); + + if (modelParameters.Values.Count > 0) + { + template.Put(modelParameters); + } + template.Put(profileMetadata); return template; } + private static AIModelParametersMetadata ParseModelParameters(Dictionary props) + { + var metadata = new AIModelParametersMetadata(); + + if (props is null || !props.TryGetValue("ModelParameters", out var raw) || string.IsNullOrWhiteSpace(raw)) + { + return metadata; + } + + foreach (var pair in raw.Split([';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separatorIndex = pair.IndexOf('='); + + if (separatorIndex <= 0 || separatorIndex == pair.Length - 1) + { + continue; + } + + var name = pair[..separatorIndex].Trim(); + var value = pair[(separatorIndex + 1)..].Trim(); + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) + { + continue; + } + + metadata.Values[name] = value; + } + + return metadata; + } + /// /// Gets parser for extension. /// diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIModelCapabilityService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIModelCapabilityService.cs new file mode 100644 index 00000000..df654197 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIModelCapabilityService.cs @@ -0,0 +1,143 @@ +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Services; + +/// +/// Default implementation of that merges the registered +/// model feature and parameter definitions with the metadata stored on a deployment. +/// +public sealed class DefaultAIModelCapabilityService : IAIModelCapabilityService +{ + private readonly AIModelCapabilityOptions _options; + private readonly IAIDeploymentStore _deploymentStore; + + /// + /// Initializes a new instance of the class. + /// + /// The registered model capability definitions. + /// The deployment store used to resolve deployments by name. + public DefaultAIModelCapabilityService( + IOptions options, + IAIDeploymentStore deploymentStore) + { + _options = options.Value; + _deploymentStore = deploymentStore; + } + + /// + public IReadOnlyList GetRegisteredFeatures() + { + return [.. _options.Features.Values + .OrderBy(feature => feature.Order) + .ThenBy(feature => feature.Name, StringComparer.OrdinalIgnoreCase)]; + } + + /// + public IReadOnlyList GetRegisteredParameters() + { + return [.. _options.Parameters.Values + .OrderBy(parameter => parameter.Order) + .ThenBy(parameter => parameter.Name, StringComparer.OrdinalIgnoreCase)]; + } + + /// + public AIDeploymentCapabilities GetCapabilities(AIDeployment deployment) + { + if (deployment is null || !deployment.TryGet(out var metadata)) + { + return AIDeploymentCapabilities.Empty; + } + + var features = new List(); + + if (metadata.Features is { Length: > 0 }) + { + foreach (var featureName in metadata.Features) + { + if (!string.IsNullOrWhiteSpace(featureName) && _options.Features.TryGetValue(featureName, out var descriptor)) + { + features.Add(descriptor); + } + } + } + + var parameters = new List(); + + if (metadata.Parameters is { Count: > 0 }) + { + foreach (var (parameterName, overrides) in metadata.Parameters) + { + if (string.IsNullOrWhiteSpace(parameterName) || !_options.Parameters.TryGetValue(parameterName, out var descriptor)) + { + continue; + } + + parameters.Add(Merge(descriptor, overrides)); + } + } + + return new AIDeploymentCapabilities(features, parameters); + } + + /// + public async ValueTask GetCapabilitiesAsync(string deploymentName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(deploymentName)) + { + return AIDeploymentCapabilities.Empty; + } + + var deployment = await _deploymentStore.FindByNameAsync(deploymentName, cancellationToken); + + return GetCapabilities(deployment); + } + + private static AIModelParameterDescriptor Merge(AIModelParameterDescriptor descriptor, AIDeploymentModelParameter overrides) + { + var effective = descriptor.Clone(); + + if (overrides is null) + { + return effective; + } + + if (overrides.AllowedValues is { Length: > 0 } && effective.AllowedValues is { Count: > 0 }) + { + effective.AllowedValues = + [ + .. effective.AllowedValues + .Where(option => overrides.AllowedValues.Any(allowed => string.Equals(allowed, option.Value, StringComparison.OrdinalIgnoreCase))) + ]; + } + + if (overrides.Minimum.HasValue) + { + effective.Minimum = overrides.Minimum; + } + + if (overrides.Maximum.HasValue) + { + effective.Maximum = overrides.Maximum; + } + + if (overrides.Step.HasValue) + { + effective.Step = overrides.Step; + } + + if (!string.IsNullOrWhiteSpace(overrides.DefaultValue)) + { + effective.DefaultValue = overrides.DefaultValue; + } + + if (!effective.IsValidValue(effective.DefaultValue)) + { + effective.DefaultValue = null; + } + + return effective; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs index 98960833..9fcd3bf9 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs @@ -165,7 +165,7 @@ public async Task CompleteAsync(IEnumerable messages, try { - var chatOptions = await GetChatOptionsAsync(context, deployment.ModelName, false); + var chatOptions = await GetChatOptionsAsync(context, deployment, false); var chatClient = await BuildClientAsync(deployment, context, chatOptions); @@ -216,7 +216,7 @@ public async IAsyncEnumerable CompleteStreamingAsync(IEnumer throw new AIDeploymentConfigurationException("The resolved chat deployment is missing a model name."); } - var chatOptions = await GetChatOptionsAsync(context, deployment.ModelName, true); + var chatOptions = await GetChatOptionsAsync(context, deployment, true); var chatClient = await BuildClientAsync(deployment, context, chatOptions); @@ -324,7 +324,7 @@ private static void AddLastMessages( } } - private async Task GetChatOptionsAsync(AICompletionContext context, string deploymentName, bool isStreaming) + private async Task GetChatOptionsAsync(AICompletionContext context, AIDeployment deployment, bool isStreaming) { var chatOptions = new ChatOptions() { @@ -335,11 +335,12 @@ private async Task GetChatOptionsAsync(AICompletionContext context, MaxOutputTokens = context.MaxTokens, }; - var supportFunctions = SupportFunctionInvocation(context, deploymentName); + var supportFunctions = SupportFunctionInvocation(context, deployment.ModelName); var configureContext = new CompletionServiceConfigureContext(chatOptions, context, supportFunctions) { - DeploymentName = deploymentName, + DeploymentName = deployment.ModelName, + Deployment = deployment, ClientName = ClientName, IsStreaming = isStreaming, }; diff --git a/src/Primitives/CrestApps.Core.AI/Services/ReasoningEffortModelParameterBinder.cs b/src/Primitives/CrestApps.Core.AI/Services/ReasoningEffortModelParameterBinder.cs new file mode 100644 index 00000000..a94e9f1c --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/ReasoningEffortModelParameterBinder.cs @@ -0,0 +1,33 @@ +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; + +namespace CrestApps.Core.AI.Services; + +/// +/// Applies the selected reasoning effort to so every provider +/// adapter that understands the standard reasoning options receives the value. +/// +public sealed class ReasoningEffortModelParameterBinder : IAIModelParameterBinder +{ + /// + public string ParameterName + => AIModelParameterNames.ReasoningEffort; + + /// + public Task BindAsync(AIModelParameterBindingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (!Enum.TryParse(context.Value, ignoreCase: true, out var effort)) + { + return Task.CompletedTask; + } + + var reasoning = context.ChatOptions.Reasoning ?? new ReasoningOptions(); + reasoning.Effort = effort; + context.ChatOptions.Reasoning = reasoning; + + return Task.CompletedTask; + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor new file mode 100644 index 00000000..a10c1e2e --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor @@ -0,0 +1,187 @@ +@using CrestApps.Core.AI.Capabilities +@using CrestApps.Core.AI.Models +@using CrestApps.Core.Blazor.Web.ViewModels +@inject IAIModelCapabilityService CapabilityService + +
+
+
Model capabilities
+
+
+

+ Declare what the underlying model supports. Features are capabilities the model either has or does not have. + Parameters are options an operator can configure on an AI profile, profile template, or chat interaction. + Anything not declared here is never rendered in those editors and is never sent to the provider. +

+ + @if (Model.AvailableFeatures.Count > 0) + { +
+ + @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "General")) + { +
+
@group.Key
+ @foreach (var feature in group) + { + var featureName = feature.Name; +
+ + +
+ } +
+ } +
+ } + + @if (Model.ModelParameters.Count > 0) + { + + @foreach (var parameter in Model.ModelParameters) + { +
+
+ + +
+ @if (parameter.IsSupported) + { +
+ @if (parameter.Kind == AIModelParameterKind.Choice && parameter.AvailableValues.Count > 0) + { +
+
+ + @foreach (var option in parameter.AvailableValues) + { + var optionValue = option.Value; +
+ + +
+ } +
Leave nothing selected to support every registered value.
+
+
+ + +
Applied when an operator does not select a value.
+
+
+ } + else + { +
+
+ + +
+ @if (parameter.Kind == AIModelParameterKind.Number || parameter.Kind == AIModelParameterKind.Integer) + { +
+ + +
+
+ + +
+
+ + +
+ } +
+ } +
+ } +
+ } + } +
+
+ +@code { + private AIDeploymentViewModel _mergedModel; + + /// + /// Gets or sets the deployment currently being edited. + /// + [Parameter] + public AIDeploymentViewModel Model { get; set; } + + /// + protected override void OnParametersSet() + { + if (Model is null || ReferenceEquals(_mergedModel, Model)) + { + return; + } + + _mergedModel = Model; + Model.MergeRegisteredCapabilities(CapabilityService.GetRegisteredFeatures(), CapabilityService.GetRegisteredParameters()); + } + + private void ToggleFeature(string featureName, ChangeEventArgs args) + { + if (args.Value is true) + { + Model.SelectedFeatures.Add(featureName); + } + else + { + Model.SelectedFeatures.Remove(featureName); + } + } + + private static void ToggleParameter(AIDeploymentModelParameterViewModel parameter, ChangeEventArgs args) + { + parameter.IsSupported = args.Value is true; + } + + private static void ToggleAllowedValue(AIDeploymentModelParameterViewModel parameter, string optionValue, ChangeEventArgs args) + { + if (args.Value is true) + { + parameter.SelectedAllowedValues.Add(optionValue); + } + else + { + parameter.SelectedAllowedValues.Remove(optionValue); + } + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor new file mode 100644 index 00000000..60c33cfc --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor @@ -0,0 +1,158 @@ +@using System.Globalization +@using CrestApps.Core.AI.Capabilities +@using CrestApps.Core.AI.Models +@inject IAIModelCapabilityService CapabilityService + +@if (_capabilities.Parameters.Count > 0) +{ + @foreach (var parameter in _capabilities.Parameters) + { + var parameterName = parameter.Name; + var elementId = $"modelParameter_{parameterName.Replace('.', '_')}"; +
+ + @if (parameter.Kind == AIModelParameterKind.Choice) + { + + } + else if (parameter.Kind == AIModelParameterKind.Boolean) + { + + } + else if (parameter.Kind == AIModelParameterKind.Number || parameter.Kind == AIModelParameterKind.Integer) + { + + } + else + { + + } + @if (parameter.Description is not null && !string.IsNullOrWhiteSpace(parameter.Description.Value)) + { +
@parameter.Description.Value
+ } +
+ } +} +else if (ShowEmptyNotice) +{ +
+ The selected deployment does not expose any configurable model parameters. +
+} + +@code { + private AIDeploymentCapabilities _capabilities = AIDeploymentCapabilities.Empty; + private string _loadedDeploymentName; + + /// + /// Gets or sets the technical name of the deployment whose parameters are rendered. + /// + [Parameter] + public string DeploymentName { get; set; } + + /// + /// Gets or sets the selected parameter values keyed by the registered parameter technical name. + /// + [Parameter] + public Dictionary Values { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets a value indicating whether a notice is rendered when the deployment exposes no parameters. + /// + [Parameter] + public bool ShowEmptyNotice { get; set; } = true; + + /// + protected override async Task OnParametersSetAsync() + { + if (string.Equals(_loadedDeploymentName, DeploymentName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _loadedDeploymentName = DeploymentName; + _capabilities = string.IsNullOrWhiteSpace(DeploymentName) + ? AIDeploymentCapabilities.Empty + : await CapabilityService.GetCapabilitiesAsync(DeploymentName); + + PruneUnsupportedValues(); + } + + private void PruneUnsupportedValues() + { + if (Values is not { Count: > 0 }) + { + return; + } + + foreach (var key in Values.Keys.ToArray()) + { + var descriptor = _capabilities.GetParameter(key); + + if (descriptor is null || !descriptor.IsValidValue(Values[key])) + { + Values.Remove(key); + } + } + } + + private string GetValue(string parameterName) + { + return Values is not null && Values.TryGetValue(parameterName, out var value) + ? value + : string.Empty; + } + + private void SetValue(string parameterName, string value) + { + if (Values is null) + { + return; + } + + if (string.IsNullOrWhiteSpace(value)) + { + Values.Remove(parameterName); + + return; + } + + Values[parameterName] = value; + } + + private static string BuildDefaultLabel(AIModelParameterDescriptor parameter) + { + return string.IsNullOrWhiteSpace(parameter.DefaultValue) + ? "Use deployment default" + : $"Use deployment default ({parameter.DefaultValue})"; + } +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor index aac1ee1a..b5d7a69f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIDeployments/Create.razor @@ -120,6 +120,8 @@ } } + +
Cache responses to improve performance for repeated queries.
+ +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ @@ -1717,6 +1721,17 @@ private static void ApplyTemplateToProfile(AIProfile profile, AIProfileTemplate template) { + if (template.TryGet(out var templateModelParameters) && templateModelParameters.Values is { Count: > 0 }) + { + profile.Alter(m => + { + foreach (var entry in templateModelParameters.Values) + { + m.Values[entry.Key] = entry.Value; + } + }); + } + if (!template.TryGet(out var metadata)) { return; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor index be183d56..c771e5b1 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor @@ -551,6 +551,10 @@ else if (_model != null)
Cache responses to improve performance for repeated queries.
+ +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor index e1bf719d..b39a6fc3 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Create.razor @@ -381,6 +381,10 @@
+ +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor index ddf759d0..e77ece43 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/Templates/Edit.razor @@ -398,6 +398,10 @@
Number of previous messages to include in context.
Cache responses to improve performance for repeated queries.
+ +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor index 9429ee1f..459b625b 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/ChatInteractions/Create.razor @@ -243,6 +243,10 @@ + +
Model parameters
+

Only the parameters declared by the selected deployment are shown.

+ } else if (_activeTab == "capabilities") { @@ -684,6 +688,15 @@ .ToArray(); }); + interaction.Alter(metadata => + { + metadata.Values = _model.ModelParameters is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary( + _model.ModelParameters.Where(static entry => !string.IsNullOrWhiteSpace(entry.Key) && !string.IsNullOrWhiteSpace(entry.Value)), + StringComparer.OrdinalIgnoreCase); + }); + if (!string.IsNullOrWhiteSpace(_model.DataSourceId)) { var dataSource = await DataSourceCatalog.FindByIdAsync(_model.DataSourceId); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs index a2b98e89..6b6cf37b 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs @@ -39,6 +39,21 @@ public sealed class AIDeploymentViewModel public List> Purposes { get; set; } = []; + /// + /// Gets or sets the technical names of the registered model features exposed by this deployment. + /// + public HashSet SelectedFeatures { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the per-deployment settings of every registered model parameter. + /// + public List ModelParameters { get; set; } = []; + + /// + /// Gets or sets every registered model feature. + /// + public List AvailableFeatures { get; set; } = []; + public static AIDeploymentViewModel FromDeployment(AIDeployment deployment) { var model = new AIDeploymentViewModel @@ -58,6 +73,26 @@ public static AIDeploymentViewModel FromDeployment(AIDeployment deployment) { model.Endpoint = deployment.Properties.TryGetValue("Endpoint", out var ep) ? ep?.ToString() : null; model.AuthenticationType = deployment.Properties.TryGetValue("AuthenticationType", out var auth) ? auth?.ToString() : null; + + var metadata = deployment.GetOrCreate(); + model.SelectedFeatures = new HashSet(metadata.Features ?? [], StringComparer.OrdinalIgnoreCase); + + if (metadata.Parameters is { Count: > 0 }) + { + model.ModelParameters = + [ + .. metadata.Parameters.Select(entry => new AIDeploymentModelParameterViewModel + { + Name = entry.Key, + IsSupported = true, + SelectedAllowedValues = new HashSet(entry.Value?.AllowedValues ?? [], StringComparer.OrdinalIgnoreCase), + DefaultValue = entry.Value?.DefaultValue, + Minimum = entry.Value?.Minimum, + Maximum = entry.Value?.Maximum, + Step = entry.Value?.Step, + }) + ]; + } } return model; @@ -95,6 +130,88 @@ public void ApplyTo(AIDeployment deployment) { deployment.Properties.Remove("AuthenticationType"); } + + ApplyModelMetadataTo(deployment); + } + + /// + /// Merges the registered feature and parameter definitions into the editor so unsaved selections + /// are preserved while display metadata is refreshed. + /// + /// The registered model features. + /// The registered model parameters. + public void MergeRegisteredCapabilities( + IEnumerable features, + IEnumerable parameters) + { + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(parameters); + + AvailableFeatures = [.. features]; + + var existing = (ModelParameters ?? []) + .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name)) + .GroupBy(parameter => parameter.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var merged = new List(); + + foreach (var descriptor in parameters) + { + if (!existing.TryGetValue(descriptor.Name, out var row)) + { + row = new AIDeploymentModelParameterViewModel + { + Name = descriptor.Name, + }; + } + + row.Descriptor = descriptor; + row.Minimum ??= descriptor.Minimum; + row.Maximum ??= descriptor.Maximum; + row.Step ??= descriptor.Step; + merged.Add(row); + } + + ModelParameters = merged; + } + + private void ApplyModelMetadataTo(AIDeployment deployment) + { + var metadata = new AIDeploymentModelMetadata + { + Features = SelectedFeatures is null + ? [] + : [.. SelectedFeatures.Where(static feature => !string.IsNullOrWhiteSpace(feature))], + }; + + foreach (var parameter in ModelParameters ?? []) + { + if (!parameter.IsSupported || string.IsNullOrWhiteSpace(parameter.Name)) + { + continue; + } + + metadata.Parameters[parameter.Name] = new AIDeploymentModelParameter + { + AllowedValues = parameter.SelectedAllowedValues is { Count: > 0 } + ? [.. parameter.SelectedAllowedValues] + : null, + DefaultValue = string.IsNullOrWhiteSpace(parameter.DefaultValue) ? null : parameter.DefaultValue, + Minimum = parameter.Minimum, + Maximum = parameter.Maximum, + Step = parameter.Step, + }; + } + + if (metadata.Features.Length == 0 && metadata.Parameters.Count == 0) + { + deployment.Remove(); + + return; + } + + deployment.Put(metadata); } public AIDeploymentPurpose GetDeploymentPurpose() @@ -123,3 +240,80 @@ public bool UsesStandaloneProvider() return _standaloneProviders.Contains(ClientName ?? string.Empty); } } + +/// +/// Represents the per-deployment settings of a single registered model parameter. +/// +public sealed class AIDeploymentModelParameterViewModel +{ + /// + /// Gets or sets the registered technical name of the parameter. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the deployment exposes this parameter. + /// + public bool IsSupported { get; set; } + + /// + /// Gets or sets the subset of registered values supported by the deployment. An empty selection + /// means every registered value is supported. + /// + public HashSet SelectedAllowedValues { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the value applied when an operator does not select one. + /// + public string DefaultValue { get; set; } + + /// + /// Gets or sets the inclusive minimum accepted value for numeric parameters. + /// + public double? Minimum { get; set; } + + /// + /// Gets or sets the inclusive maximum accepted value for numeric parameters. + /// + public double? Maximum { get; set; } + + /// + /// Gets or sets the increment applied by numeric editors. + /// + public double? Step { get; set; } + + /// + /// Gets or sets the registered descriptor backing this row. + /// + public AIModelParameterDescriptor Descriptor { get; set; } + + /// + /// Gets the display text of the registered parameter. + /// + public string DisplayName + => Descriptor?.DisplayName?.Value ?? Name; + + /// + /// Gets the descriptive text of the registered parameter. + /// + public string Description + => Descriptor?.Description?.Value; + + /// + /// Gets the editor semantics of the registered parameter. + /// + public AIModelParameterKind Kind + => Descriptor?.Kind ?? AIModelParameterKind.Text; + + /// + /// Gets every value registered for a choice parameter. + /// + public IList AvailableValues + => Descriptor?.AllowedValues ?? []; + + /// + /// Gets a slug safe for use inside an element identifier. + /// + public string ElementId + => Name?.Replace('.', '_'); +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs index 16a7729a..1728084d 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIProfileViewModel.cs @@ -212,6 +212,12 @@ public sealed class AIProfileViewModel public List> AnthropicAvailableModels { get; set; } = []; + /// + /// Gets or sets the model parameter values selected for this profile, keyed by the registered + /// parameter technical name. + /// + public Dictionary ModelParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public static AIProfileViewModel FromProfile(AIProfile profile) { var settings = profile.GetOrCreateSettings(); @@ -272,6 +278,11 @@ public static AIProfileViewModel FromProfile(AIProfile profile) EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; + if (profile.TryGet(out var modelParameters) && modelParameters.Values is { Count: > 0 }) + { + vm.ModelParameters = new Dictionary(modelParameters.Values, StringComparer.OrdinalIgnoreCase); + } + if (profile.TryGet(out var metadata)) { vm.AddInitialPrompt = !string.IsNullOrEmpty(metadata.InitialPrompt); @@ -430,6 +441,15 @@ public void ApplyTo(AIProfile profile) m.UseCaching = UseCaching; }); + profile.Alter(m => + { + m.Values = ModelParameters is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary( + ModelParameters.Where(static entry => !string.IsNullOrWhiteSpace(entry.Key) && !string.IsNullOrWhiteSpace(entry.Value)), + StringComparer.OrdinalIgnoreCase); + }); + profile.AlterSettings(s => { s.LockSystemMessage = LockSystemMessage; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs index a4cd4890..17cf6350 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AITemplateViewModel.cs @@ -192,6 +192,12 @@ public sealed class AITemplateViewModel public List> DataSources { get; set; } = []; + /// + /// Gets or sets the model parameter values selected for this template, keyed by the registered + /// parameter technical name. + /// + public Dictionary ModelParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public static AITemplateViewModel FromTemplate(AIProfileTemplate template) { var model = new AITemplateViewModel @@ -214,6 +220,11 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) } else if (template.Source == AITemplateSources.Profile) { + if (template.TryGet(out var modelParameters) && modelParameters.Values is { Count: > 0 }) + { + model.ModelParameters = new Dictionary(modelParameters.Values, StringComparer.OrdinalIgnoreCase); + } + if (template.TryGet(out var metadata)) { model.ProfileType = metadata.ProfileType; @@ -392,6 +403,14 @@ public void ApplyTo(AIProfileTemplate template) { var toolNames = SelectedToolNames?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); var agentNames = SelectedAgentNames?.Where(n => !string.IsNullOrWhiteSpace(n)).ToArray(); + var selectedModelParameters = ModelParameters is null + ? [] + : ModelParameters.Where(static entry => !string.IsNullOrWhiteSpace(entry.Key) && !string.IsNullOrWhiteSpace(entry.Value)); + + template.Put(new AIModelParametersMetadata + { + Values = new Dictionary(selectedModelParameters, StringComparer.OrdinalIgnoreCase), + }); template.Put(new ProfileTemplateMetadata { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs index 17d1b285..3b46ee7d 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/ChatInteractionViewModel.cs @@ -93,6 +93,12 @@ public sealed class ChatInteractionViewModel public List AnthropicAvailableModels { get; set; } = []; public bool AllowImageUploads { get; set; } public bool AllowDocumentUploads { get; set; } = true; + + /// + /// Gets or sets the model parameter values selected for this interaction, keyed by the registered + /// parameter technical name. + /// + public Dictionary ModelParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); } public sealed class SelectOption diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs index d3ed6c6c..39fb36b2 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.AI.Capabilities; using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; @@ -16,6 +17,7 @@ public sealed class AIDeploymentController : Controller private readonly IAIDeploymentStore _deploymentStore; private readonly INamedSourceCatalog _deploymentCatalog; private readonly IAIProviderConnectionStore _connectionCatalog; + private readonly IAIModelCapabilityService _capabilityService; private static readonly List _providers = [ @@ -36,11 +38,13 @@ public sealed class AIDeploymentController : Controller public AIDeploymentController( IAIDeploymentStore deploymentStore, INamedSourceCatalog deploymentCatalog, - IAIProviderConnectionStore connectionCatalog) + IAIProviderConnectionStore connectionCatalog, + IAIModelCapabilityService capabilityService) { _deploymentStore = deploymentStore; _deploymentCatalog = deploymentCatalog; _connectionCatalog = connectionCatalog; + _capabilityService = capabilityService; } public async Task Index() @@ -258,6 +262,8 @@ private async Task PopulateDropdownsAsync(AIDeploymentViewModel model) .Where(static purpose => purpose != AIDeploymentPurpose.None) .Select(static purpose => new SelectListItem(purpose.ToString(), purpose.ToString())) .ToList(); + + model.MergeRegisteredCapabilities(_capabilityService.GetRegisteredFeatures(), _capabilityService.GetRegisteredParameters()); } private async Task ValidateUniqueNameAsync(string technicalName, string currentItemId = null) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index 38440207..dfaa4b68 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -20,6 +20,7 @@ using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; +using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using CrestApps.Core.Templates.Services; @@ -53,6 +54,8 @@ public sealed class AIProfileController : Controller private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; private readonly IAIDataSourceStore _dataSourceStore; + private readonly AIModelParameterViewService _modelParameterViewService; + public AIProfileController( IAIProfileManager profileManager, ICatalog deploymentCatalog, @@ -72,7 +75,8 @@ public AIProfileController( IOptionsSnapshot copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions, - IAIDataSourceStore dataSourceStore) + IAIDataSourceStore dataSourceStore, + AIModelParameterViewService modelParameterViewService) { _profileManager = profileManager; _deploymentCatalog = deploymentCatalog; @@ -93,6 +97,7 @@ public AIProfileController( _oauthService = oauthService; _toolOptions = toolOptions.Value; _dataSourceStore = dataSourceStore; + _modelParameterViewService = modelParameterViewService; } public async Task Index() @@ -255,6 +260,8 @@ public async Task Delete(string id) private async Task PopulateDropdownsAsync(AIProfileViewModel model) { + model.ModelParameterEditor = await _modelParameterViewService.BuildAsync(model.ModelParameters); + var allDeployments = await _deploymentCatalog.GetAllAsync(); model.ChatDeployments = allDeployments.Where(d => d.Purpose.Supports(AIDeploymentPurpose.Chat)).Select(d => new SelectListItem(BuildDeploymentLabel(d), d.Name)).ToList(); model.UtilityDeployments = allDeployments.Where(d => d.Purpose.Supports(AIDeploymentPurpose.Utility) || d.Purpose.Supports(AIDeploymentPurpose.Chat)).Select(d => new SelectListItem(BuildDeploymentLabel(d), d.Name)).ToList(); @@ -417,6 +424,17 @@ private async Task PopulateAttachedDocumentsAsync(AIProfileViewModel model, stri private static void ApplyTemplateToProfile(AIProfile profile, AIProfileTemplate template) { + if (template.TryGet(out var templateModelParameters) && templateModelParameters.Values is { Count: > 0 }) + { + profile.Alter(m => + { + foreach (var entry in templateModelParameters.Values) + { + m.Values[entry.Key] = entry.Value; + } + }); + } + if (!template.TryGet(out var metadata)) { return; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs index 8ff24bbc..24f65360 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs @@ -18,6 +18,7 @@ using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; +using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using CrestApps.Core.Templates.Services; @@ -48,6 +49,8 @@ public sealed class AITemplateController : Controller private readonly IOptionsSnapshot _copilotOptions; private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; + private readonly AIModelParameterViewService _modelParameterViewService; + public AITemplateController( ICatalog catalog, ICatalog deploymentCatalog, @@ -64,7 +67,8 @@ public AITemplateController( ClaudeClientService anthropicClientService, IOptionsSnapshot copilotOptions, GitHubOAuthService oauthService, - IOptions toolOptions) + IOptions toolOptions, + AIModelParameterViewService modelParameterViewService) { _catalog = catalog; _deploymentCatalog = deploymentCatalog; @@ -82,6 +86,7 @@ public AITemplateController( _copilotOptions = copilotOptions; _oauthService = oauthService; _toolOptions = toolOptions.Value; + _modelParameterViewService = modelParameterViewService; } public async Task Index() @@ -191,6 +196,8 @@ public async Task Delete(string id) private async Task PopulateDropdownsAsync(AITemplateViewModel model) { + model.ModelParameterEditor = await _modelParameterViewService.BuildAsync(model.ModelParameters); + var allDeployments = await _deploymentCatalog.GetAllAsync(); model.ChatDeployments = allDeployments.Where(d => d.Purpose.Supports(AIDeploymentPurpose.Chat)).Select(d => new SelectListItem(BuildDeploymentLabel(d), d.Name)).ToList(); model.UtilityDeployments = allDeployments.Where(d => d.Purpose.Supports(AIDeploymentPurpose.Utility) || d.Purpose.Supports(AIDeploymentPurpose.Chat)).Select(d => new SelectListItem(BuildDeploymentLabel(d), d.Name)).ToList(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs index 1b6e190f..4b0abc41 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs @@ -5,6 +5,111 @@ namespace CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; +/// +/// Represents the per-deployment settings of a single registered model parameter. +/// +public sealed class AIDeploymentModelParameterViewModel +{ + /// + /// Gets or sets the registered technical name of the parameter. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the deployment exposes this parameter. + /// + public bool IsSupported { get; set; } + + /// + /// Gets or sets the subset of registered values supported by the deployment. An empty selection + /// means every registered value is supported. + /// + public string[] SelectedAllowedValues { get; set; } = []; + + /// + /// Gets or sets the value applied when an operator does not select one. + /// + public string DefaultValue { get; set; } + + /// + /// Gets or sets the inclusive minimum accepted value for numeric parameters. + /// + public double? Minimum { get; set; } + + /// + /// Gets or sets the inclusive maximum accepted value for numeric parameters. + /// + public double? Maximum { get; set; } + + /// + /// Gets or sets the increment applied by numeric editors. + /// + public double? Step { get; set; } + + /// + /// Gets or sets the display text of the registered parameter. + /// + [BindNever] + public string DisplayName { get; set; } + + /// + /// Gets or sets the descriptive text of the registered parameter. + /// + [BindNever] + public string Description { get; set; } + + /// + /// Gets or sets the editor semantics of the registered parameter. + /// + [BindNever] + public AIModelParameterKind Kind { get; set; } + + /// + /// Gets or sets every value registered for a choice parameter. + /// + [BindNever] + public IEnumerable AvailableValues { get; set; } = []; + + /// + /// Gets a slug safe for use inside an element identifier. + /// + [BindNever] + public string ElementId + => Name?.Replace('.', '_'); +} + +/// +/// Represents a registered model feature that a deployment can expose. +/// +public sealed class AIDeploymentModelFeatureViewModel +{ + /// + /// Gets or sets the registered technical name of the feature. + /// + public string Name { get; set; } + + /// + /// Gets or sets the display text of the registered feature. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the descriptive text of the registered feature. + /// + public string Description { get; set; } + + /// + /// Gets or sets the grouping category of the registered feature. + /// + public string Category { get; set; } + + /// + /// Gets a slug safe for use inside an element identifier. + /// + public string ElementId + => Name?.Replace('.', '_'); +} + public sealed class AIDeploymentViewModel { private static readonly HashSet _standaloneProviders = new(StringComparer.OrdinalIgnoreCase) @@ -43,6 +148,22 @@ public sealed class AIDeploymentViewModel [BindNever] public IEnumerable Purposes { get; set; } = []; + /// + /// Gets or sets the technical names of the registered model features exposed by this deployment. + /// + public string[] SelectedFeatures { get; set; } = []; + + /// + /// Gets or sets the per-deployment settings of every registered model parameter. + /// + public List ModelParameters { get; set; } = []; + + /// + /// Gets or sets every registered model feature. + /// + [BindNever] + public IEnumerable AvailableFeatures { get; set; } = []; + public static AIDeploymentViewModel FromDeployment(AIDeployment deployment) { var model = new AIDeploymentViewModel @@ -62,11 +183,88 @@ public static AIDeploymentViewModel FromDeployment(AIDeployment deployment) { model.Endpoint = deployment.Properties.TryGetValue("Endpoint", out var ep) ? ep?.ToString() : null; model.AuthenticationType = deployment.Properties.TryGetValue("AuthenticationType", out var auth) ? auth?.ToString() : null; + + var metadata = deployment.GetOrCreate(); + model.SelectedFeatures = metadata.Features ?? []; + + if (metadata.Parameters is { Count: > 0 }) + { + model.ModelParameters = + [ + .. metadata.Parameters.Select(entry => new AIDeploymentModelParameterViewModel + { + Name = entry.Key, + IsSupported = true, + SelectedAllowedValues = entry.Value?.AllowedValues ?? [], + DefaultValue = entry.Value?.DefaultValue, + Minimum = entry.Value?.Minimum, + Maximum = entry.Value?.Maximum, + Step = entry.Value?.Step, + }) + ]; + } } return model; } + /// + /// Merges the registered feature and parameter definitions into the editor so unsaved selections + /// are preserved while display metadata is refreshed. + /// + /// The registered model features. + /// The registered model parameters. + public void MergeRegisteredCapabilities( + IEnumerable features, + IEnumerable parameters) + { + ArgumentNullException.ThrowIfNull(features); + ArgumentNullException.ThrowIfNull(parameters); + + AvailableFeatures = + [ + .. features.Select(feature => new AIDeploymentModelFeatureViewModel + { + Name = feature.Name, + DisplayName = feature.DisplayName?.Value ?? feature.Name, + Description = feature.Description?.Value, + Category = feature.Category, + }) + ]; + + var existing = (ModelParameters ?? []) + .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Name)) + .GroupBy(parameter => parameter.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var merged = new List(); + + foreach (var descriptor in parameters) + { + if (!existing.TryGetValue(descriptor.Name, out var row)) + { + row = new AIDeploymentModelParameterViewModel + { + Name = descriptor.Name, + }; + } + + row.DisplayName = descriptor.DisplayName?.Value ?? descriptor.Name; + row.Description = descriptor.Description?.Value; + row.Kind = descriptor.Kind; + row.AvailableValues = + [ + .. descriptor.AllowedValues.Select(option => new SelectListItem(option.DisplayName?.Value ?? option.Value, option.Value)) + ]; + row.Minimum ??= descriptor.Minimum; + row.Maximum ??= descriptor.Maximum; + row.Step ??= descriptor.Step; + merged.Add(row); + } + + ModelParameters = merged; + } + public void ApplyTo(AIDeployment deployment) { deployment.Name = TechnicalName; @@ -99,6 +297,46 @@ public void ApplyTo(AIDeployment deployment) { deployment.Properties.Remove("AuthenticationType"); } + + ApplyModelMetadataTo(deployment); + } + + private void ApplyModelMetadataTo(AIDeployment deployment) + { + var metadata = new AIDeploymentModelMetadata + { + Features = SelectedFeatures is null + ? [] + : [.. SelectedFeatures.Where(static feature => !string.IsNullOrWhiteSpace(feature)).Distinct(StringComparer.OrdinalIgnoreCase)], + }; + + foreach (var parameter in ModelParameters ?? []) + { + if (!parameter.IsSupported || string.IsNullOrWhiteSpace(parameter.Name)) + { + continue; + } + + metadata.Parameters[parameter.Name] = new AIDeploymentModelParameter + { + AllowedValues = parameter.SelectedAllowedValues is { Length: > 0 } + ? [.. parameter.SelectedAllowedValues.Where(static value => !string.IsNullOrWhiteSpace(value))] + : null, + DefaultValue = string.IsNullOrWhiteSpace(parameter.DefaultValue) ? null : parameter.DefaultValue, + Minimum = parameter.Minimum, + Maximum = parameter.Maximum, + Step = parameter.Step, + }; + } + + if (metadata.Features.Length == 0 && metadata.Parameters.Count == 0) + { + deployment.Remove(); + + return; + } + + deployment.Put(metadata); } public AIDeploymentPurpose GetDeploymentPurpose() diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index 25038c16..413ef781 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -13,6 +13,7 @@ using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; +using CrestApps.Core.Mvc.Web.Models; using CrestApps.Core.Templates.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; @@ -203,6 +204,18 @@ public sealed class AIProfileViewModel [BindNever] public IEnumerable AnthropicAvailableModels { get; set; } = []; + /// + /// Gets or sets the model parameter values selected for this profile, keyed by the registered + /// parameter technical name. + /// + public Dictionary ModelParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the metadata-driven model parameter editor. + /// + [BindNever] + public ModelParameterEditorViewModel ModelParameterEditor { get; set; } + public static AIProfileViewModel FromProfile(AIProfile profile) { var settings = profile.GetOrCreateSettings(); @@ -258,6 +271,11 @@ public static AIProfileViewModel FromProfile(AIProfile profile) EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; + if (profile.TryGet(out var modelParameters) && modelParameters.Values is { Count: > 0 }) + { + vm.ModelParameters = new Dictionary(modelParameters.Values, StringComparer.OrdinalIgnoreCase); + } + if (profile.TryGet(out var metadata)) { vm.AddInitialPrompt = !string.IsNullOrEmpty(metadata.InitialPrompt); @@ -418,6 +436,15 @@ public void ApplyTo(AIProfile profile) m.UseCaching = UseCaching; }); + profile.Alter(m => + { + m.Values = ModelParameters is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary( + ModelParameters.Where(static entry => !string.IsNullOrWhiteSpace(entry.Key) && !string.IsNullOrWhiteSpace(entry.Value)), + StringComparer.OrdinalIgnoreCase); + }); + profile.AlterSettings(s => { s.LockSystemMessage = LockSystemMessage; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs index 34849c72..dea0f13e 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs @@ -11,6 +11,7 @@ using CrestApps.Core.Mvc.Web.Areas.A2A.ViewModels; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; +using CrestApps.Core.Mvc.Web.Models; using CrestApps.Core.Templates.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; @@ -176,6 +177,18 @@ public sealed class AITemplateViewModel [BindNever] public IEnumerable DataSources { get; set; } = []; + /// + /// Gets or sets the model parameter values selected for this template, keyed by the registered + /// parameter technical name. + /// + public Dictionary ModelParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the metadata-driven model parameter editor. + /// + [BindNever] + public ModelParameterEditorViewModel ModelParameterEditor { get; set; } + public static AITemplateViewModel FromTemplate(AIProfileTemplate template) { var model = new AITemplateViewModel @@ -198,6 +211,11 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) } else if (template.Source == AITemplateSources.Profile) { + if (template.TryGet(out var modelParameters) && modelParameters.Values is { Count: > 0 }) + { + model.ModelParameters = new Dictionary(modelParameters.Values, StringComparer.OrdinalIgnoreCase); + } + if (template.TryGet(out var metadata)) { model.ProfileType = metadata.ProfileType; @@ -402,6 +420,15 @@ public void ApplyTo(AIProfileTemplate template) .ToArray() ?? [], }); + var selectedModelParameters = ModelParameters is null + ? [] + : ModelParameters.Where(static entry => !string.IsNullOrWhiteSpace(entry.Key) && !string.IsNullOrWhiteSpace(entry.Value)); + + template.Put(new AIModelParametersMetadata + { + Values = new Dictionary(selectedModelParameters, StringComparer.OrdinalIgnoreCase), + }); + template.Put(new AIProfileMcpMetadata { ConnectionIds = SelectedMcpConnectionIds? diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml index 1e066da9..381e3a14 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIDeployment/Create.cshtml @@ -75,6 +75,8 @@ + +
+ +
Cache responses to improve performance for repeated queries.
+ @if (Model.ModelParameterEditor is not null && Model.ModelParameterEditor.HasParameters) + { +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ + } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml index 1900b8e7..747fec5a 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml @@ -439,6 +439,12 @@
Cache responses to improve performance for repeated queries.
+ @if (Model.ModelParameterEditor is not null && Model.ModelParameterEditor.HasParameters) + { +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ + } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml index bcd5b636..3a0e6fae 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml @@ -382,6 +382,12 @@
Cache responses to improve performance for repeated queries.
+ @if (Model.ModelParameterEditor is not null && Model.ModelParameterEditor.HasParameters) + { +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ + } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml index 39388dde..3f329b1f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml @@ -373,6 +373,12 @@
Cache responses to improve performance for repeated queries.
+ @if (Model.ModelParameterEditor is not null && Model.ModelParameterEditor.HasParameters) + { +
Model parameters
+

Only the parameters declared by the selected chat deployment are shown.

+ + } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs index 9d12fc36..aa67fdee 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs @@ -24,6 +24,7 @@ using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; +using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Services; using CrestApps.Core.Templates.Services; @@ -63,6 +64,7 @@ public sealed class ChatInteractionController : Controller private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; private readonly ISourceCatalog _toolInstanceCatalog; + private readonly AIModelParameterViewService _modelParameterViewService; public ChatInteractionController( ICatalogManager interactionManager, @@ -88,7 +90,8 @@ public ChatInteractionController( IOptionsSnapshot copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions, - ISourceCatalog toolInstanceCatalog) + ISourceCatalog toolInstanceCatalog, + AIModelParameterViewService modelParameterViewService) { _interactionManager = interactionManager; _promptStore = promptStore; @@ -114,6 +117,7 @@ public ChatInteractionController( _oauthService = oauthService; _toolOptions = toolOptions.Value; _toolInstanceCatalog = toolInstanceCatalog; + _modelParameterViewService = modelParameterViewService; } public async Task Index() @@ -290,6 +294,8 @@ public async Task Delete(string id) private async Task PopulateDropdownsAsync(ChatInteractionViewModel model) { + model.ModelParameterEditor = await _modelParameterViewService.BuildAsync(model.ModelParameters); + var deployments = await _deploymentCatalog.GetAllAsync(); model.Deployments = deployments .Where(d => d.Purpose.Supports(AIDeploymentPurpose.Chat)) @@ -627,6 +633,15 @@ private async Task ApplyMetadataAsync(ChatInteraction interaction, ChatInteracti metadata.ToolInstanceNames = toolInstanceNames.ToArray(); }); + interaction.Alter(metadata => + { + metadata.Values = model.ModelParameters is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary( + model.ModelParameters.Where(static entry => !string.IsNullOrWhiteSpace(entry.Key) && !string.IsNullOrWhiteSpace(entry.Value)), + StringComparer.OrdinalIgnoreCase); + }); + if (string.Equals(model.OrchestratorName, ClaudeOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) { interaction.Alter(metadata => diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/ViewModels/ChatInteractionViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/ViewModels/ChatInteractionViewModel.cs index d627627c..93796e69 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/ViewModels/ChatInteractionViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/ViewModels/ChatInteractionViewModel.cs @@ -3,6 +3,7 @@ using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Mcp.ViewModels; using CrestApps.Core.Mvc.Web.Areas.Tooling.ViewModels; +using CrestApps.Core.Mvc.Web.Models; using CrestApps.Core.Templates.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; @@ -120,6 +121,18 @@ public sealed class ChatInteractionViewModel [BindNever] public bool AllowDocumentUploads { get; set; } = true; + + /// + /// Gets or sets the model parameter values selected for this interaction, keyed by the registered + /// parameter technical name. + /// + public Dictionary ModelParameters { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the metadata-driven model parameter editor. + /// + [BindNever] + public ModelParameterEditorViewModel ModelParameterEditor { get; set; } } public sealed class AgentSelectionItem diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Create.cshtml index 00d14a47..c77a3ee0 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Views/ChatInteraction/Create.cshtml @@ -283,6 +283,12 @@ + @if (Model.ModelParameterEditor is not null && Model.ModelParameterEditor.HasParameters) + { +
Model parameters
+

Only the parameters declared by the selected deployment are shown.

+ + } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs new file mode 100644 index 00000000..0de4596a --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.Mvc.Web.Models; + +/// +/// Backs the metadata-driven model parameter editor rendered by the _ModelParameters partial. +/// +public sealed class ModelParameterEditorViewModel +{ + /// + /// Gets or sets the name of the form field that holds the selected chat deployment. + /// + public string DeploymentFieldName { get; set; } = "ChatDeploymentName"; + + /// + /// Gets or sets the form field prefix used when posting the selected parameter values. + /// + public string FieldPrefix { get; set; } = "ModelParameters"; + + /// + /// Gets or sets the prefix applied to generated element identifiers so a page can render + /// more than one editor without colliding. + /// + public string ElementPrefix { get; set; } = "modelParameters"; + + /// + /// Gets or sets every registered model parameter along with the value currently selected. + /// + public List Parameters { get; set; } = []; + + /// + /// Gets or sets the per-deployment capability map serialized as JSON and consumed by the editor script. + /// + public string CapabilitiesJson { get; set; } = "{}"; + + /// + /// Gets a value indicating whether at least one parameter is registered. + /// + public bool HasParameters + => Parameters.Count > 0; +} + +/// +/// Represents a single registered model parameter rendered by the editor. +/// +public sealed class ModelParameterFieldViewModel +{ + /// + /// Gets or sets the registered technical name of the parameter. + /// + public string Name { get; set; } + + /// + /// Gets or sets the display text shown to operators. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the descriptive text shown to operators. + /// + public string Description { get; set; } + + /// + /// Gets or sets the editor semantics of the parameter. + /// + public AIModelParameterKind Kind { get; set; } + + /// + /// Gets or sets every value registered for a choice parameter. + /// + public List AllowedValues { get; set; } = []; + + /// + /// Gets or sets the value currently selected. + /// + public string Value { get; set; } + + /// + /// Gets a slug safe for use inside an element identifier. + /// + public string ElementId + => Name?.Replace('.', '_'); +} + +/// +/// Represents a selectable value of a choice parameter. +/// +public sealed class ModelParameterOptionViewModel +{ + /// + /// Gets or sets the technical value posted by the editor. + /// + public string Value { get; set; } + + /// + /// Gets or sets the display text shown to operators. + /// + public string DisplayName { get; set; } +} + +/// +/// Describes the effective metadata of a single parameter for one deployment. The shape of this type +/// matches the JSON consumed by the editor script. +/// +public sealed class ModelParameterCapabilityViewModel +{ + /// + /// Gets or sets the values supported by the deployment, or when every + /// registered value is supported. + /// + public string[] AllowedValues { get; set; } + + /// + /// Gets or sets the value applied when the operator does not select one. + /// + public string DefaultValue { get; set; } + + /// + /// Gets or sets the inclusive minimum accepted value for numeric parameters. + /// + public double? Minimum { get; set; } + + /// + /// Gets or sets the inclusive maximum accepted value for numeric parameters. + /// + public double? Maximum { get; set; } + + /// + /// Gets or sets the increment applied by numeric editors. + /// + public double? Step { get; set; } + + /// + /// Gets the serializer options used when the capability map is written for the editor script. + /// + public static JsonSerializerOptions SerializerOptions { get; } = new(JsonSerializerDefaults.Web); +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs new file mode 100644 index 00000000..5effda22 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Mvc.Web.Models; +using CrestApps.Core.Services; + +namespace CrestApps.Core.Mvc.Web.Services; + +/// +/// Builds the metadata-driven model parameter editor from the registered parameter definitions and +/// the metadata declared by each AI deployment. +/// +public sealed class AIModelParameterViewService +{ + private readonly IAIModelCapabilityService _capabilityService; + private readonly ICatalog _deploymentCatalog; + + /// + /// Initializes a new instance of the class. + /// + /// The capability service. + /// The deployment catalog. + public AIModelParameterViewService( + IAIModelCapabilityService capabilityService, + ICatalog deploymentCatalog) + { + _capabilityService = capabilityService; + _deploymentCatalog = deploymentCatalog; + } + + /// + /// Builds the editor model for the given selected values. + /// + /// The values currently selected, keyed by parameter technical name. + /// The name of the form field that holds the selected chat deployment. + /// The form field prefix used when posting the selected values. + /// The prefix applied to generated element identifiers. + public async Task BuildAsync( + IDictionary values, + string deploymentFieldName = "ChatDeploymentName", + string fieldPrefix = "ModelParameters", + string elementPrefix = "modelParameters") + { + var model = new ModelParameterEditorViewModel + { + DeploymentFieldName = deploymentFieldName, + FieldPrefix = fieldPrefix, + ElementPrefix = elementPrefix, + }; + + foreach (var descriptor in _capabilityService.GetRegisteredParameters()) + { + model.Parameters.Add(new ModelParameterFieldViewModel + { + Name = descriptor.Name, + DisplayName = descriptor.DisplayName?.Value ?? descriptor.Name, + Description = descriptor.Description?.Value, + Kind = descriptor.Kind, + Value = values is not null && values.TryGetValue(descriptor.Name, out var value) ? value : null, + AllowedValues = [.. descriptor.AllowedValues.Select(option => new ModelParameterOptionViewModel + { + Value = option.Value, + DisplayName = option.DisplayName?.Value ?? option.Value, + })], + }); + } + + model.CapabilitiesJson = JsonSerializer.Serialize(await BuildCapabilityMapAsync(), ModelParameterCapabilityViewModel.SerializerOptions); + + return model; + } + + private async Task>> BuildCapabilityMapAsync() + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var deployments = await _deploymentCatalog.GetAllAsync(); + + foreach (var deployment in deployments) + { + if (string.IsNullOrWhiteSpace(deployment.Name)) + { + continue; + } + + var capabilities = _capabilityService.GetCapabilities(deployment); + + if (capabilities.Parameters.Count == 0) + { + continue; + } + + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var parameter in capabilities.Parameters) + { + entries[parameter.Name] = new ModelParameterCapabilityViewModel + { + AllowedValues = parameter.AllowedValues is { Count: > 0 } + ? [.. parameter.AllowedValues.Select(option => option.Value)] + : null, + DefaultValue = parameter.DefaultValue, + Minimum = parameter.Minimum, + Maximum = parameter.Maximum, + Step = parameter.Step, + }; + } + + map[deployment.Name] = entries; + } + + return map; + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index c0d91061..6dffa6ee 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -73,7 +73,8 @@ public static IServiceCollection AddMvcSampleHostServices(this IServiceCollectio services .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); services .AddScoped, AIMemoryEntryHandler>() diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml new file mode 100644 index 00000000..02d23b50 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml @@ -0,0 +1,180 @@ +@using CrestApps.Core.AI.Models +@using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels +@model AIDeploymentViewModel + +
+
+
Model capabilities
+
+
+

+ Declare what the underlying model supports. Features are capabilities the model either has or does not have. + Parameters are options an operator can configure on an AI profile, profile template, or chat interaction. + Anything not declared here is never rendered in those editors and is never sent to the provider. +

+ + @if (Model.AvailableFeatures.Any()) + { +
+ + @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "General")) + { +
+
@group.Key
+ @foreach (var feature in group) + { + var isChecked = Model.SelectedFeatures?.Contains(feature.Name, StringComparer.OrdinalIgnoreCase) ?? false; +
+ + +
+ } +
+ } +
+ } + + @if (Model.ModelParameters.Count > 0) + { + + @for (var i = 0; i < Model.ModelParameters.Count; i++) + { + var parameter = Model.ModelParameters[i]; + var bodyId = $"modelParameterBody_{parameter.ElementId}"; +
+ +
+ + + +
+
+ @if (parameter.Kind == AIModelParameterKind.Choice && parameter.AvailableValues.Any()) + { +
+
+ + +
Leave nothing selected to support every registered value.
+
+
+ + +
Applied when an operator does not select a value.
+
+
+ } + else + { +
+
+ + +
+ @if (parameter.Kind == AIModelParameterKind.Number || parameter.Kind == AIModelParameterKind.Integer) + { +
+ + +
+
+ + +
+
+ + +
+ } +
+ } +
+
+ } + } +
+
+ + diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_ModelParameters.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_ModelParameters.cshtml new file mode 100644 index 00000000..131d4486 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_ModelParameters.cshtml @@ -0,0 +1,208 @@ +@using CrestApps.Core.AI.Models +@model CrestApps.Core.Mvc.Web.Models.ModelParameterEditorViewModel + +@if (Model is not null && Model.HasParameters) +{ +
+
+ The selected deployment does not expose any configurable model parameters. +
+ @foreach (var parameter in Model.Parameters) + { + var fieldId = $"{Model.ElementPrefix}-{parameter.ElementId}"; + var fieldName = $"{Model.FieldPrefix}[{parameter.Name}]"; +
+ + @switch (parameter.Kind) + { + case AIModelParameterKind.Choice: + + break; + case AIModelParameterKind.Boolean: + + break; + case AIModelParameterKind.Number: + case AIModelParameterKind.Integer: + + break; + default: + + break; + } + @if (!string.IsNullOrWhiteSpace(parameter.Description)) + { +
@parameter.Description
+ } +
+ } +
+ + +} diff --git a/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs new file mode 100644 index 00000000..fd939d4f --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs @@ -0,0 +1,400 @@ +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CrestApps.Core.Tests.Framework.AI; + +public sealed class AIModelCapabilityTests +{ + [Fact] + public void GetCapabilities_WhenDeploymentDeclaresNoMetadata_ShouldReturnEmpty() + { + // Arrange + var service = CreateService(out _); + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + // Act + var capabilities = service.GetCapabilities(deployment); + + // Assert + Assert.Empty(capabilities.Features); + Assert.Empty(capabilities.Parameters); + } + + [Fact] + public void GetCapabilities_WhenDeploymentDeclaresParameter_ShouldReturnRegisteredMetadata() + { + // Arrange + var service = CreateService(out _); + var deployment = CreateDeployment(new AIDeploymentModelParameter()); + + // Act + var capabilities = service.GetCapabilities(deployment); + var descriptor = capabilities.GetParameter(AIModelParameterNames.ReasoningEffort); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal(AIModelParameterKind.Choice, descriptor.Kind); + Assert.Equal(3, descriptor.AllowedValues.Count); + Assert.Equal("Medium", descriptor.DefaultValue); + } + + [Fact] + public void GetCapabilities_WhenDeploymentNarrowsAllowedValues_ShouldOnlyExposeSupportedValues() + { + // Arrange + var service = CreateService(out _); + var deployment = CreateDeployment(new AIDeploymentModelParameter + { + AllowedValues = ["Low", "High"], + }); + + // Act + var descriptor = service.GetCapabilities(deployment).GetParameter(AIModelParameterNames.ReasoningEffort); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal(["Low", "High"], descriptor.AllowedValues.Select(option => option.Value)); + } + + [Fact] + public void GetCapabilities_WhenRegisteredDefaultIsNotSupported_ShouldClearTheDefault() + { + // Arrange + var service = CreateService(out _); + var deployment = CreateDeployment(new AIDeploymentModelParameter + { + AllowedValues = ["Low"], + }); + + // Act + var descriptor = service.GetCapabilities(deployment).GetParameter(AIModelParameterNames.ReasoningEffort); + + // Assert + Assert.NotNull(descriptor); + Assert.Null(descriptor.DefaultValue); + } + + [Fact] + public void GetCapabilities_WhenDeploymentOverridesDefault_ShouldUseTheOverride() + { + // Arrange + var service = CreateService(out _); + var deployment = CreateDeployment(new AIDeploymentModelParameter + { + DefaultValue = "High", + }); + + // Act + var descriptor = service.GetCapabilities(deployment).GetParameter(AIModelParameterNames.ReasoningEffort); + + // Assert + Assert.NotNull(descriptor); + Assert.Equal("High", descriptor.DefaultValue); + } + + [Fact] + public void GetCapabilities_ShouldNotMutateTheRegisteredDescriptor() + { + // Arrange + var service = CreateService(out var options); + var deployment = CreateDeployment(new AIDeploymentModelParameter + { + AllowedValues = ["Low"], + DefaultValue = "Low", + }); + + // Act + service.GetCapabilities(deployment); + + // Assert + var registered = options.Parameters[AIModelParameterNames.ReasoningEffort]; + Assert.Equal(3, registered.AllowedValues.Count); + Assert.Equal("Medium", registered.DefaultValue); + } + + [Theory] + [InlineData("Low", true)] + [InlineData("low", true)] + [InlineData("Insane", false)] + [InlineData("", true)] + public void IsValidValue_ForChoiceParameter_ShouldValidateAgainstAllowedValues(string value, bool expected) + { + // Arrange + var descriptor = CreateReasoningEffortDescriptor(); + + // Act + var result = descriptor.IsValidValue(value); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("0.5", true)] + [InlineData("2.5", false)] + [InlineData("not-a-number", false)] + public void IsValidValue_ForNumberParameter_ShouldHonorRange(string value, bool expected) + { + // Arrange + var descriptor = new AIModelParameterDescriptor + { + Name = "sampling", + Kind = AIModelParameterKind.Number, + Minimum = 0, + Maximum = 2, + }; + + // Act + var result = descriptor.IsValidValue(value); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void IsValidValue_ForIntegerParameter_ShouldRejectFractionalValues() + { + // Arrange + var descriptor = new AIModelParameterDescriptor + { + Name = "seed", + Kind = AIModelParameterKind.Integer, + }; + + // Act & Assert + Assert.True(descriptor.IsValidValue("12")); + Assert.False(descriptor.IsValidValue("12.5")); + } + + [Fact] + public async Task ConfigureAsync_WhenDeploymentExposesReasoningEffort_ShouldApplyTheSelectedValue() + { + // Arrange + var handler = CreateHandler(); + var context = CreateConfigureContext(CreateDeployment(new AIDeploymentModelParameter()), ("reasoningEffort", "High")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ReasoningEffort.High, context.ChatOptions.Reasoning?.Effort); + } + + [Fact] + public async Task ConfigureAsync_WhenDeploymentDoesNotExposeTheParameter_ShouldNotSendIt() + { + // Arrange + var handler = CreateHandler(); + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + var context = CreateConfigureContext(deployment, ("reasoningEffort", "High")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(context.ChatOptions.Reasoning); + Assert.Null(context.ChatOptions.AdditionalProperties); + } + + [Fact] + public async Task ConfigureAsync_WhenTheSelectedValueIsNotSupported_ShouldFallBackToTheDeploymentDefault() + { + // Arrange + var handler = CreateHandler(); + var deployment = CreateDeployment(new AIDeploymentModelParameter + { + AllowedValues = ["Low", "Medium"], + DefaultValue = "Low", + }); + var context = CreateConfigureContext(deployment, ("reasoningEffort", "High")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ReasoningEffort.Low, context.ChatOptions.Reasoning?.Effort); + } + + [Fact] + public async Task ConfigureAsync_WhenNoValueIsSelected_ShouldApplyTheDeploymentDefault() + { + // Arrange + var handler = CreateHandler(); + var context = CreateConfigureContext(CreateDeployment(new AIDeploymentModelParameter + { + DefaultValue = "Low", + })); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ReasoningEffort.Low, context.ChatOptions.Reasoning?.Effort); + } + + [Fact] + public async Task ConfigureAsync_WhenNoBinderIsRegistered_ShouldWriteToAdditionalProperties() + { + // Arrange + var options = CreateOptions(); + options.AddParameter("verbosity", new LocalizedString("Verbosity", "Verbosity"), descriptor => + { + descriptor.Kind = AIModelParameterKind.Choice; + descriptor.AllowedValues = + [ + new AIModelParameterOption { Value = "low" }, + new AIModelParameterOption { Value = "high" }, + ]; + }); + + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["verbosity"] = new AIDeploymentModelParameter(), + }, + }); + + var handler = CreateHandler(options); + var context = CreateConfigureContext(deployment, ("verbosity", "high")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(context.ChatOptions.AdditionalProperties); + Assert.Equal("high", context.ChatOptions.AdditionalProperties["verbosity"]); + } + + [Fact] + public void ApplyModelParameters_ShouldCopyTheStoredValuesIntoTheCompletionContext() + { + // Arrange + var context = new AICompletionContext(); + var metadata = new AIModelParametersMetadata + { + Values = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["reasoningEffort"] = "High", + ["ignored"] = " ", + }, + }; + + // Act + context.ApplyModelParameters(metadata); + + // Assert + Assert.Equal("High", context.ModelParameters["reasoningEffort"]); + Assert.False(context.ModelParameters.ContainsKey("ignored")); + } + + private static AIDeployment CreateDeployment(AIDeploymentModelParameter parameter) + { + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Features = [AIModelFeatureNames.Reasoning], + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AIModelParameterNames.ReasoningEffort] = parameter, + }, + }); + + return deployment; + } + + private static CompletionServiceConfigureContext CreateConfigureContext(AIDeployment deployment, params (string Name, string Value)[] values) + { + var completionContext = new AICompletionContext + { + ChatDeploymentName = deployment.Name, + }; + + foreach (var (name, value) in values) + { + completionContext.ModelParameters[name] = value; + } + + return new CompletionServiceConfigureContext(new ChatOptions(), completionContext, isFunctionInvocationSupported: true) + { + Deployment = deployment, + DeploymentName = deployment.Name, + }; + } + + private static AIModelParameterDescriptor CreateReasoningEffortDescriptor() + { + return new AIModelParameterDescriptor + { + Name = AIModelParameterNames.ReasoningEffort, + DisplayName = new LocalizedString("Reasoning effort", "Reasoning effort"), + Kind = AIModelParameterKind.Choice, + DefaultValue = "Medium", + AllowedValues = + [ + new AIModelParameterOption { Value = "Low" }, + new AIModelParameterOption { Value = "Medium" }, + new AIModelParameterOption { Value = "High" }, + ], + }; + } + + private static AIModelCapabilityOptions CreateOptions() + { + var options = new AIModelCapabilityOptions(); + + options.AddFeature(AIModelFeatureNames.Reasoning, new LocalizedString("Reasoning", "Reasoning")); + + var reasoningEffort = CreateReasoningEffortDescriptor(); + + options.AddParameter(reasoningEffort.Name, reasoningEffort.DisplayName, descriptor => + { + descriptor.Kind = reasoningEffort.Kind; + descriptor.DefaultValue = reasoningEffort.DefaultValue; + descriptor.AllowedValues = reasoningEffort.AllowedValues; + }); + + return options; + } + + private static DefaultAIModelCapabilityService CreateService(out AIModelCapabilityOptions options) + { + options = CreateOptions(); + + return new DefaultAIModelCapabilityService(Options.Create(options), Mock.Of()); + } + + private static ModelParametersAICompletionServiceHandler CreateHandler(AIModelCapabilityOptions options = null) + { + var service = new DefaultAIModelCapabilityService(Options.Create(options ?? CreateOptions()), Mock.Of()); + + return new ModelParametersAICompletionServiceHandler( + service, + [new ReasoningEffortModelParameterBinder()], + NullLogger.Instance); + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs index b31d9f7d..e68a2579 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs @@ -1,5 +1,6 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.AzureAIInference; +using CrestApps.Core.AI.Capabilities; using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; @@ -331,7 +332,8 @@ public async Task AIDeploymentController_Create_ShouldPopulateConnectionsFromMer var controller = new AIDeploymentController( deploymentStore.Object, deploymentCatalog.Object, - connectionCatalog.Object); + connectionCatalog.Object, + CreateCapabilityService()); var result = await controller.Create(); @@ -353,7 +355,8 @@ public async Task AIDeploymentController_Create_ShouldRequireProviderAndSharedCo var controller = new AIDeploymentController( deploymentStore.Object, deploymentCatalog.Object, - connectionCatalog.Object); + connectionCatalog.Object, + CreateCapabilityService()); var result = await controller.Create(new AIDeploymentViewModel { @@ -493,7 +496,8 @@ public async Task AIDeploymentController_Index_ShouldMarkConfiguredDeploymentsAs var controller = new AIDeploymentController( deploymentStore.Object, deploymentCatalog.Object, - connectionCatalog.Object); + connectionCatalog.Object, + CreateCapabilityService()); var result = await controller.Index(); @@ -606,6 +610,11 @@ public async Task ConfigurationAIProviderConnectionStore_ShouldSkipConfiguredCon Assert.Equal("ui-connection", connections.Single().ItemId); } + private static DefaultAIModelCapabilityService CreateCapabilityService() + { + return new DefaultAIModelCapabilityService(Options.Create(new AIModelCapabilityOptions()), Mock.Of()); + } + private static DefaultAIProviderConnectionStore CreateConnectionStore( IConfiguration configuration, AIProviderConnectionCatalogOptions catalogOptions = null, From dc347c3b01b53a25e5ba654ff686263fc9e81eae Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 8 Aug 2026 01:20:58 +0300 Subject: [PATCH 2/6] Refine AI deployment trained-feature capabilities and enforcement - List only genuine trained model capabilities under "Trained features" (add image/audio/video input/output; drop provider-hosted web search) - Add AIModelFeatureDescriptor.EnabledByDefault; default toolCalling + streaming on - Add ModelFeaturesAICompletionServiceHandler to enforce features at runtime, stripping tools/ToolMode and JSON response format when not declared (opt-in on metadata) - MVC + Blazor: "Trained features" heading, default-on features, read-only capability badges in profile/template/chat editors - MVC: bootstrap-select searchable multi-select on deployment allowed-values pickers - Update docs and 1.1.0 changelog; add enforcement + registration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AIModelFeatureDescriptor.cs | 6 + .../Models/AIModelFeatureNames.cs | 18 +- .../docs/changelog/1.1.0.md | 24 ++- .../docs/core/ai-model-capabilities.md | 66 ++++-- ...ModelFeaturesAICompletionServiceHandler.cs | 69 +++++++ .../ServiceCollectionExtensions.cs | 28 ++- .../Forms/ModelCapabilitiesEditor.razor | 15 +- .../Forms/ModelParametersEditor.razor | 14 ++ .../AI/Controllers/AIDeploymentController.cs | 7 + .../AI/ViewModels/AIDeploymentViewModel.cs | 6 + .../Models/ModelParameterEditorViewModel.cs | 7 + .../Services/AIModelParameterViewService.cs | 26 +++ .../_DeploymentModelCapabilities.cshtml | 21 +- .../Views/Shared/_Layout.cshtml | 2 + .../Views/Shared/_ModelParameters.cshtml | 64 +++++- ...FeaturesAICompletionServiceHandlerTests.cs | 190 ++++++++++++++++++ 16 files changed, 524 insertions(+), 39 deletions(-) create mode 100644 src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs create mode 100644 tests/CrestApps.Core.Tests/Framework/AI/ModelFeaturesAICompletionServiceHandlerTests.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs index 76e3a6ff..a7607b43 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureDescriptor.cs @@ -38,4 +38,10 @@ public sealed class AIModelFeatureDescriptor /// Gets or sets the sort order used when features are listed. Lower values are listed first. /// public int Order { get; set; } + + /// + /// Gets or sets a value indicating whether the feature is selected by default when a new + /// deployment is created. Existing deployments are unaffected by this value. + /// + public bool EnabledByDefault { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs index 1e6f17e7..31148092 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs @@ -26,6 +26,16 @@ public static class AIModelFeatureNames /// public const string Reasoning = "reasoning"; + /// + /// The model can understand image inputs (vision). + /// + public const string ImageInput = "imageInput"; + + /// + /// The model can generate images. + /// + public const string ImageOutput = "imageOutput"; + /// /// The model accepts audio input. /// @@ -37,12 +47,12 @@ public static class AIModelFeatureNames public const string AudioOutput = "audioOutput"; /// - /// The model can operate a computer or browser environment. + /// The model can understand video inputs. /// - public const string ComputerUse = "computerUse"; + public const string VideoInput = "videoInput"; /// - /// The model can search the web as part of producing a response. + /// The model can operate a computer or browser environment. /// - public const string WebSearch = "webSearch"; + public const string ComputerUse = "computerUse"; } diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 8e39b77a..8e2c1e37 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -30,8 +30,17 @@ guide. values, ranges, and defaults), registered through the new `AddAIModelFeature` and `AddAIModelParameter` service-collection extensions - adds `AddCoreAIModelCapabilities()`, chained automatically by `AddCoreAIServices()`, which registers - the capability service, the completion handler, the reasoning-effort binder, eight built-in - features, and the built-in `reasoningEffort` parameter + the capability service, the completion handlers, the reasoning-effort binder, ten built-in + **trained** features (`toolCalling`, `structuredOutputs`, `streaming`, `reasoning`, `imageInput`, + `imageOutput`, `audioInput`, `audioOutput`, `videoInput`, and `computerUse`), and the built-in + `reasoningEffort` parameter. Features model genuine trained model capabilities; provider-hosted + tools such as web search are intentionally not registered as features +- adds `AIModelFeatureDescriptor.EnabledByDefault` so a registered feature can be pre-selected on newly + created deployments, and flags `toolCalling` and `streaming` as enabled by default +- adds `ModelFeaturesAICompletionServiceHandler`, a runtime enforcement point for features that runs + after the tool-adding handlers and, for deployments that declare capability metadata, removes tools + and `ToolMode` when `toolCalling` is not declared and removes a JSON response format when + `structuredOutputs` is not declared. Deployments without metadata stay unconstrained - adds `AIDeploymentModelMetadata` so a deployment declares the features and parameters its model exposes and can narrow the allowed values, default, or numeric bounds of a registered parameter. The metadata flows through configuration and recipes with no additional code because deployment @@ -54,10 +63,17 @@ guide. being configured. `DeploymentName` continues to carry the model name - updates `CrestApps.Core.Mvc.Web` with a reusable deployment capability editor and a metadata-driven model parameter editor wired into the AI deployment, AI profile, AI profile template, and chat - interaction screens. Unsupported fields are hidden and disabled so they are never submitted + interaction screens. Unsupported fields are hidden and disabled so they are never submitted. The + deployment editor groups features under a **Trained features** heading, pre-checks the default + features on new deployments, and renders the allowed-values selectors with the + [`@crestapps/bootstrap-select`](https://github.com/CrestApps/bootstrap-select) searchable + multi-select. The AI profile, profile template, and chat interaction editors show the selected + deployment's declared trained capabilities as read-only badges that update when the deployment changes - updates `CrestApps.Core.Blazor.Web` with the equivalent `ModelCapabilitiesEditor` and `ModelParametersEditor` components, which re-render when the selected deployment changes and prune - values the new deployment does not support + values the new deployment does not support. The capabilities editor uses the **Trained features** + heading and pre-selects the default features on new deployments, and the parameters editor shows the + deployment's declared trained capabilities as read-only badges ## Fixes diff --git a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md index 47f439d6..6d7b7787 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md @@ -48,16 +48,24 @@ You rarely need to call this directly — `AddCoreAIServices()` chains it automa ### Features -| Name | Constant | Description | -| --- | --- | --- | -| `toolCalling` | `AIModelFeatureNames.ToolCalling` | The model can call tools and functions supplied with the request. | -| `structuredOutputs` | `AIModelFeatureNames.StructuredOutputs` | The model can return responses that follow a supplied JSON schema. | -| `streaming` | `AIModelFeatureNames.Streaming` | The model can stream response updates as they are produced. | -| `reasoning` | `AIModelFeatureNames.Reasoning` | The model performs internal reasoning before producing an answer. | -| `audioInput` | `AIModelFeatureNames.AudioInput` | The model accepts audio input. | -| `audioOutput` | `AIModelFeatureNames.AudioOutput` | The model produces audio output. | -| `webSearch` | `AIModelFeatureNames.WebSearch` | The model can search the web while producing a response. | -| `computerUse` | `AIModelFeatureNames.ComputerUse` | The model can operate a computer or browser environment. | +| Name | Constant | Default on new deployments | Description | +| --- | --- | --- | --- | +| `toolCalling` | `AIModelFeatureNames.ToolCalling` | ✅ | The model can call tools and functions supplied with the request. | +| `structuredOutputs` | `AIModelFeatureNames.StructuredOutputs` | | The model can return responses that follow a supplied JSON schema. | +| `streaming` | `AIModelFeatureNames.Streaming` | ✅ | The model can stream response updates as they are produced. | +| `reasoning` | `AIModelFeatureNames.Reasoning` | | The model performs internal reasoning before producing an answer. | +| `imageInput` | `AIModelFeatureNames.ImageInput` | | The model can understand image inputs (vision). | +| `imageOutput` | `AIModelFeatureNames.ImageOutput` | | The model can generate images. | +| `audioInput` | `AIModelFeatureNames.AudioInput` | | The model accepts audio input. | +| `audioOutput` | `AIModelFeatureNames.AudioOutput` | | The model produces audio output. | +| `videoInput` | `AIModelFeatureNames.VideoInput` | | The model can understand video inputs. | +| `computerUse` | `AIModelFeatureNames.ComputerUse` | | The model can operate a computer or browser environment. | + +These represent **trained capabilities** the underlying model was built with. Provider-hosted tools +(such as a web-search tool the provider runs on your behalf) are *not* modeled as features because +almost any tool-calling model can be handed such a tool — they are ordinary tools, not a trained +trait. Set `AIModelFeatureDescriptor.EnabledByDefault` when registering a feature to pre-select it on +newly created deployments; existing deployments are never changed by this flag. ### Parameters @@ -214,6 +222,23 @@ enforcement point: `ReasoningEffortModelParameterBinder` implements step 3 for `reasoningEffort` by setting `ChatOptions.Reasoning.Effort`. +### Feature enforcement + +`ModelFeaturesAICompletionServiceHandler` enforces the **features** a deployment declares. It runs +after the tool-adding handlers so it can strip options that depend on an unsupported trained +capability: + +- When the deployment does not declare `toolCalling`, any `ChatOptions.Tools` and `ChatOptions.ToolMode` + are cleared before the request leaves the process. +- When the deployment does not declare `structuredOutputs`, a JSON `ChatOptions.ResponseFormat` is + removed. + +Enforcement is **opt-in**: it only applies to deployments that declare capability metadata. A +deployment with no `AIDeploymentModelMetadata` is treated as unconstrained, so existing configurations +keep working unchanged. Combined with the parameter handler above, this guarantees that neither +unsupported parameters (for example `reasoningEffort`) nor unsupported options (tools, structured +output) are sent to a model that was not trained for them. + :::note Azure OpenAI builds `OpenAI.Chat.ChatCompletionOptions` directly instead of going through `Microsoft.Extensions.AI.ChatOptions`. `AzureOpenAICompletionClient` therefore translates the resolved @@ -227,12 +252,12 @@ Any module can contribute definitions during startup. ```csharp services.AddAIModelFeature( - "imageInput", - new LocalizedString("imageInput", "Image input"), + "webSearch", + new LocalizedString("webSearch", "Web search"), feature => { - feature.Description = new LocalizedString("imageInput", "The model accepts image input."); - feature.Order = 90; + feature.Description = new LocalizedString("webSearch", "The provider runs a hosted web-search tool for this model."); + feature.Order = 200; }); services.AddAIModelParameter( @@ -297,17 +322,22 @@ built, the `CompletionContext`, and the `Deployment`. Both sample hosts render the metadata rather than hardcoding options. -- **AI Deployment editor** — lists every registered feature as a checkbox and every registered +- **AI Deployment editor** — lists every registered feature as a checkbox under a **Trained features** + heading (features flagged `EnabledByDefault` are pre-checked on new deployments) and every registered parameter with a *supported* toggle, an allowed-values selector, a default value, and numeric bounds where applicable. - **AI Profile, AI Profile Template, and Chat Interaction editors** — render only the parameters the - selected deployment supports, restricted to that deployment's allowed values. + selected deployment supports, restricted to that deployment's allowed values, and show the selected + deployment's declared trained capabilities as read-only badges so operators can see what the model + supports. In `CrestApps.Core.Mvc.Web` the server renders every registered parameter inside hidden, disabled wrappers together with a deployment-to-capability JSON map; a small script shows, enables, and filters the fields when the deployment selection changes. Disabled inputs are not posted, so an unsupported -value can never be submitted. In `CrestApps.Core.Blazor.Web` the components re-render reactively and -prune values that the newly selected deployment does not support. +value can never be submitted. The deployment editor's allowed-values selectors use the +[`@crestapps/bootstrap-select`](https://github.com/CrestApps/bootstrap-select) picker for a searchable +multi-select experience. In `CrestApps.Core.Blazor.Web` the components re-render reactively and prune +values that the newly selected deployment does not support. :::note The GitHub Copilot and Claude orchestrators keep their own effort settings diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs new file mode 100644 index 00000000..eb74851a --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs @@ -0,0 +1,69 @@ +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Completions; +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Handlers; + +/// +/// Enforces the trained features declared by the resolved deployment so that request options which +/// depend on an unsupported capability are never sent to a provider. Enforcement is opt-in: only +/// deployments that declare capability metadata are constrained, which keeps deployments without +/// declared capabilities fully unconstrained. +/// +public sealed class ModelFeaturesAICompletionServiceHandler : IAICompletionServiceHandler +{ + private readonly IAIModelCapabilityService _capabilityService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The capability service used to resolve deployment metadata. + /// The logger. + public ModelFeaturesAICompletionServiceHandler( + IAIModelCapabilityService capabilityService, + ILogger logger) + { + _capabilityService = capabilityService; + _logger = logger; + } + + /// + public Task ConfigureAsync(CompletionServiceConfigureContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + // Feature enforcement is opt-in: only deployments that declare their capability metadata + // constrain the request. Deployments without metadata are treated as unconstrained so that + // existing configurations keep working exactly as before. + if (context.Deployment is null || !context.Deployment.TryGet(out _)) + { + return Task.CompletedTask; + } + + var capabilities = _capabilityService.GetCapabilities(context.Deployment); + + if (!capabilities.SupportsFeature(AIModelFeatureNames.ToolCalling) && context.ChatOptions.Tools is { Count: > 0 }) + { + _logger.LogWarning( + "Deployment '{Deployment}' does not declare the '{Feature}' feature. {Count} tool(s) were removed from the request.", + context.DeploymentName, AIModelFeatureNames.ToolCalling, context.ChatOptions.Tools.Count); + + context.ChatOptions.Tools = null; + context.ChatOptions.ToolMode = null; + } + + if (!capabilities.SupportsFeature(AIModelFeatureNames.StructuredOutputs) && context.ChatOptions.ResponseFormat is ChatResponseFormatJson) + { + _logger.LogWarning( + "Deployment '{Deployment}' does not declare the '{Feature}' feature. The JSON response format was removed from the request.", + context.DeploymentName, AIModelFeatureNames.StructuredOutputs); + + context.ChatOptions.ResponseFormat = null; + } + + return Task.CompletedTask; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 91f7c2c6..5bd6a8a2 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -216,6 +216,7 @@ public static IServiceCollection AddCoreAIModelCapabilities(this IServiceCollect { feature.Description = new LocalizedString(AIModelFeatureNames.ToolCalling, "The model can call tools and functions supplied with the request."); feature.Order = 10; + feature.EnabledByDefault = true; }) .AddAIModelFeature(AIModelFeatureNames.StructuredOutputs, new LocalizedString(AIModelFeatureNames.StructuredOutputs, "Structured outputs"), feature => { @@ -226,31 +227,42 @@ public static IServiceCollection AddCoreAIModelCapabilities(this IServiceCollect { feature.Description = new LocalizedString(AIModelFeatureNames.Streaming, "The model can stream response updates as they are produced."); feature.Order = 30; + feature.EnabledByDefault = true; }) .AddAIModelFeature(AIModelFeatureNames.Reasoning, new LocalizedString(AIModelFeatureNames.Reasoning, "Reasoning"), feature => { feature.Description = new LocalizedString(AIModelFeatureNames.Reasoning, "The model performs internal reasoning before producing an answer."); feature.Order = 40; }) + .AddAIModelFeature(AIModelFeatureNames.ImageInput, new LocalizedString(AIModelFeatureNames.ImageInput, "Image input (vision)"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.ImageInput, "The model can understand image inputs."); + feature.Order = 50; + }) + .AddAIModelFeature(AIModelFeatureNames.ImageOutput, new LocalizedString(AIModelFeatureNames.ImageOutput, "Image output"), feature => + { + feature.Description = new LocalizedString(AIModelFeatureNames.ImageOutput, "The model can generate images."); + feature.Order = 60; + }) .AddAIModelFeature(AIModelFeatureNames.AudioInput, new LocalizedString(AIModelFeatureNames.AudioInput, "Audio input"), feature => { feature.Description = new LocalizedString(AIModelFeatureNames.AudioInput, "The model accepts audio input."); - feature.Order = 50; + feature.Order = 70; }) .AddAIModelFeature(AIModelFeatureNames.AudioOutput, new LocalizedString(AIModelFeatureNames.AudioOutput, "Audio output"), feature => { feature.Description = new LocalizedString(AIModelFeatureNames.AudioOutput, "The model produces audio output."); - feature.Order = 60; + feature.Order = 80; }) - .AddAIModelFeature(AIModelFeatureNames.WebSearch, new LocalizedString(AIModelFeatureNames.WebSearch, "Web search"), feature => + .AddAIModelFeature(AIModelFeatureNames.VideoInput, new LocalizedString(AIModelFeatureNames.VideoInput, "Video input"), feature => { - feature.Description = new LocalizedString(AIModelFeatureNames.WebSearch, "The model can search the web while producing a response."); - feature.Order = 70; + feature.Description = new LocalizedString(AIModelFeatureNames.VideoInput, "The model can understand video inputs."); + feature.Order = 90; }) .AddAIModelFeature(AIModelFeatureNames.ComputerUse, new LocalizedString(AIModelFeatureNames.ComputerUse, "Computer use"), feature => { feature.Description = new LocalizedString(AIModelFeatureNames.ComputerUse, "The model can operate a computer or browser environment."); - feature.Order = 80; + feature.Order = 100; }); services.AddAIModelParameter(AIModelParameterNames.ReasoningEffort, new LocalizedString(AIModelParameterNames.ReasoningEffort, "Reasoning effort"), parameter => @@ -647,6 +659,10 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection services.TryAddEnumerable(ServiceDescriptor.Scoped()); + // Registered after the tool-adding handler so it can strip tools and other options that the + // resolved deployment does not declare support for. + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.AddOrchestrator(DefaultOrchestrator.OrchestratorName) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor index a10c1e2e..7f50f81b 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor @@ -17,8 +17,9 @@ @if (Model.AvailableFeatures.Count > 0) {
- - @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "General")) + +

Capabilities the underlying model was trained with. Only declared features are sent to the provider.

+ @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "Trained Features")) {
@group.Key
@@ -154,6 +155,16 @@ _mergedModel = Model; Model.MergeRegisteredCapabilities(CapabilityService.GetRegisteredFeatures(), CapabilityService.GetRegisteredParameters()); + + // Apply the default trained features once when a brand-new deployment is being created. + // Existing deployments (identified by an item id) keep their saved selection untouched. + if (string.IsNullOrEmpty(Model.ItemId) && Model.SelectedFeatures.Count == 0) + { + foreach (var feature in Model.AvailableFeatures.Where(static feature => feature.EnabledByDefault)) + { + Model.SelectedFeatures.Add(feature.Name); + } + } } private void ToggleFeature(string featureName, ChangeEventArgs args) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor index 60c33cfc..22ac580f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelParametersEditor.razor @@ -3,6 +3,20 @@ @using CrestApps.Core.AI.Models @inject IAIModelCapabilityService CapabilityService +@if (_capabilities.Features.Count > 0) +{ +
+ +
+ @foreach (var feature in _capabilities.Features) + { + @(feature.DisplayName?.Value ?? feature.Name) + } +
+
Trained capabilities the selected deployment declares support for.
+
+} + @if (_capabilities.Parameters.Count > 0) { @foreach (var parameter in _capabilities.Parameters) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs index 39fb36b2..046d8d78 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs @@ -62,6 +62,13 @@ public async Task Create() var model = new AIDeploymentViewModel(); await PopulateDropdownsAsync(model); + model.SelectedFeatures = + [ + .. model.AvailableFeatures + .Where(static feature => feature.EnabledByDefault) + .Select(static feature => feature.Name) + ]; + return View(model); } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs index 4b0abc41..7d80a437 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs @@ -103,6 +103,11 @@ public sealed class AIDeploymentModelFeatureViewModel /// public string Category { get; set; } + /// + /// Gets or sets a value indicating whether the feature is selected by default on new deployments. + /// + public bool EnabledByDefault { get; set; } + /// /// Gets a slug safe for use inside an element identifier. /// @@ -229,6 +234,7 @@ public void MergeRegisteredCapabilities( DisplayName = feature.DisplayName?.Value ?? feature.Name, Description = feature.Description?.Value, Category = feature.Category, + EnabledByDefault = feature.EnabledByDefault, }) ]; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs index 0de4596a..d99bba9e 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Models/ModelParameterEditorViewModel.cs @@ -34,6 +34,13 @@ public sealed class ModelParameterEditorViewModel /// public string CapabilitiesJson { get; set; } = "{}"; + /// + /// Gets or sets the per-deployment trained feature map serialized as JSON and consumed by the editor + /// script to render the read-only capability badges. Keyed by deployment name, each value is the list + /// of trained feature display names the deployment declares. + /// + public string FeaturesJson { get; set; } = "{}"; + /// /// Gets a value indicating whether at least one parameter is registered. /// diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs index 5effda22..74a16896 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/AIModelParameterViewService.cs @@ -66,10 +66,36 @@ public async Task BuildAsync( } model.CapabilitiesJson = JsonSerializer.Serialize(await BuildCapabilityMapAsync(), ModelParameterCapabilityViewModel.SerializerOptions); + model.FeaturesJson = JsonSerializer.Serialize(await BuildFeatureMapAsync(), ModelParameterCapabilityViewModel.SerializerOptions); return model; } + private async Task> BuildFeatureMapAsync() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var deployments = await _deploymentCatalog.GetAllAsync(); + + foreach (var deployment in deployments) + { + if (string.IsNullOrWhiteSpace(deployment.Name)) + { + continue; + } + + var capabilities = _capabilityService.GetCapabilities(deployment); + + if (capabilities.Features.Count == 0) + { + continue; + } + + map[deployment.Name] = [.. capabilities.Features.Select(feature => feature.DisplayName?.Value ?? feature.Name)]; + } + + return map; + } + private async Task>> BuildCapabilityMapAsync() { var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml index 02d23b50..901b5e79 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml @@ -16,8 +16,9 @@ @if (Model.AvailableFeatures.Any()) {
- - @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "General")) + +

Capabilities the underlying model was trained with. Only declared features are sent to the provider.

+ @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "Trained Features")) {
@group.Key
@@ -79,8 +80,11 @@ - + @@ -184,7 +184,7 @@
- + @await RenderSectionAsync("Scripts", required: false) @await Html.PartialAsync("_ToastNotifications") From 7bfe2fa39859b43e50a723ac3611d310332a7803 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 8 Aug 2026 01:41:17 +0300 Subject: [PATCH 4/6] Link model parameters to trained features in deployment editor - Add AIModelParameterDescriptor.RequiredFeature so a parameter can declare a dependency on a trained feature; wire the built-in reasoningEffort parameter to require the reasoning feature - MVC and Blazor deployment editors only show a dependent parameter while its feature is enabled, and clear/disable it when the feature is turned off so a contradictory config (e.g. reasoningEffort on a non-reasoning model) can't be saved - Update docs and 1.1.0 changelog; add RequiredFeature registration and Clone tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AIModelParameterDescriptor.cs | 8 ++++ .../docs/changelog/1.1.0.md | 5 ++- .../docs/core/ai-model-capabilities.md | 30 ++++++++++++- .../ServiceCollectionExtensions.cs | 1 + .../Forms/ModelCapabilitiesEditor.razor | 19 +++++++++ .../ViewModels/AIDeploymentViewModel.cs | 7 ++++ .../AI/ViewModels/AIDeploymentViewModel.cs | 8 ++++ .../_DeploymentModelCapabilities.cshtml | 42 ++++++++++++++++++- ...FeaturesAICompletionServiceHandlerTests.cs | 18 ++++++++ 9 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs index 939f9cb2..3e9f1e1f 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs @@ -64,6 +64,13 @@ public sealed class AIModelParameterDescriptor /// public string Category { get; set; } + /// + /// Gets or sets the optional name of a trained feature this parameter depends on. When set, the + /// parameter is only meaningful for deployments that declare the matching + /// , and editors should hide it unless that feature is enabled. + /// + public string RequiredFeature { get; set; } + /// /// Gets or sets the sort order used when parameters are listed. Lower values are listed first. /// @@ -89,6 +96,7 @@ public AIModelParameterDescriptor Clone() Step = Step, DefaultValue = DefaultValue, Category = Category, + RequiredFeature = RequiredFeature, Order = Order, }; } diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 8e2c1e37..b115f3bf 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -37,7 +37,10 @@ guide. tools such as web search are intentionally not registered as features - adds `AIModelFeatureDescriptor.EnabledByDefault` so a registered feature can be pre-selected on newly created deployments, and flags `toolCalling` and `streaming` as enabled by default -- adds `ModelFeaturesAICompletionServiceHandler`, a runtime enforcement point for features that runs +- adds `AIModelParameterDescriptor.RequiredFeature` so a parameter can declare a dependency on a trained + feature. The built-in `reasoningEffort` parameter now requires the `reasoning` feature, and the + deployment editors only show a dependent parameter while its feature is enabled, clearing it when the + feature is turned off so a contradictory combination cannot be saved after the tool-adding handlers and, for deployments that declare capability metadata, removes tools and `ToolMode` when `toolCalling` is not declared and removes a JSON response format when `structuredOutputs` is not declared. Deployments without metadata stay unconstrained diff --git a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md index 6d7b7787..07380a3c 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md @@ -74,7 +74,9 @@ newly created deployments; existing deployments are never changed by this flag. | `reasoningEffort` | `AIModelParameterNames.ReasoningEffort` | `Choice` | `None` (shown as *Minimal*), `Low`, `Medium`, `High`, `ExtraHigh` | `Medium` | `reasoningEffort` maps onto `Microsoft.Extensions.AI.ChatOptions.Reasoning.Effort`, so it is -provider-agnostic and ships in the core AI package rather than in a provider module. +provider-agnostic and ships in the core AI package rather than in a provider module. It also declares +`RequiredFeature = AIModelFeatureNames.Reasoning`, which links the parameter to the `reasoning` trained +feature (see [Linking a parameter to a feature](#linking-a-parameter-to-a-feature)). ## Declaring what a deployment supports @@ -279,6 +281,29 @@ services.AddAIModelParameter( Registering the same name again updates the existing descriptor instead of adding a duplicate, so a provider module can refine a definition contributed by another module. +### Linking a parameter to a feature + +A parameter can declare that it only applies when the model exposes a specific trained feature by +setting `RequiredFeature` to the feature name. The built-in `reasoningEffort` parameter uses this to +depend on the `reasoning` feature: + +```csharp +services.AddAIModelParameter( + AIModelParameterNames.ReasoningEffort, + new LocalizedString(AIModelParameterNames.ReasoningEffort, "Reasoning effort"), + parameter => + { + parameter.Kind = AIModelParameterKind.Choice; + parameter.RequiredFeature = AIModelFeatureNames.Reasoning; + // allowed values, default, etc. + }); +``` + +When `RequiredFeature` is set, the deployment editor only shows the parameter while the matching +feature checkbox is enabled, and clearing the feature also clears the dependent parameter so a +contradictory combination (for example a `reasoningEffort` value on a model that is not a reasoning +model) can never be saved. + ### Parameter kinds | Kind | Editor | Notes | @@ -325,7 +350,8 @@ Both sample hosts render the metadata rather than hardcoding options. - **AI Deployment editor** — lists every registered feature as a checkbox under a **Trained features** heading (features flagged `EnabledByDefault` are pre-checked on new deployments) and every registered parameter with a *supported* toggle, an allowed-values selector, a default value, and numeric bounds - where applicable. + where applicable. A parameter that declares a `RequiredFeature` is only shown while the matching + feature is enabled. - **AI Profile, AI Profile Template, and Chat Interaction editors** — render only the parameters the selected deployment supports, restricted to that deployment's allowed values, and show the selected deployment's declared trained capabilities as read-only badges so operators can see what the model diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 5bd6a8a2..5b34daec 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -270,6 +270,7 @@ public static IServiceCollection AddCoreAIModelCapabilities(this IServiceCollect parameter.Description = new LocalizedString(AIModelParameterNames.ReasoningEffort, "Controls how much internal reasoning the model applies before answering. Higher values produce more thoughtful answers with increased latency and cost."); parameter.Kind = AIModelParameterKind.Choice; parameter.DefaultValue = nameof(ReasoningEffort.Medium); + parameter.RequiredFeature = AIModelFeatureNames.Reasoning; parameter.Order = 10; parameter.AllowedValues = [ diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor index 7f50f81b..3cea6449 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor @@ -51,6 +51,10 @@ @foreach (var parameter in Model.ModelParameters) { + if (!IsParameterAvailable(parameter)) + { + continue; + }
string.Equals(parameter.RequiredFeature, featureName, StringComparison.OrdinalIgnoreCase))) + { + parameter.IsSupported = false; + } } } + private bool IsParameterAvailable(AIDeploymentModelParameterViewModel parameter) + { + if (string.IsNullOrWhiteSpace(parameter.RequiredFeature)) + { + return true; + } + + return Model.SelectedFeatures.Contains(parameter.RequiredFeature, StringComparer.OrdinalIgnoreCase); + } + private static void ToggleParameter(AIDeploymentModelParameterViewModel parameter, ChangeEventArgs args) { parameter.IsSupported = args.Value is true; diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs index 6b6cf37b..f7289582 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDeploymentViewModel.cs @@ -305,6 +305,13 @@ public string Description public AIModelParameterKind Kind => Descriptor?.Kind ?? AIModelParameterKind.Text; + /// + /// Gets the optional trained feature this parameter depends on. When set, the editor only shows the + /// parameter while the matching feature is enabled. + /// + public string RequiredFeature + => Descriptor?.RequiredFeature; + /// /// Gets every value registered for a choice parameter. /// diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs index 7d80a437..71083a61 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs @@ -64,6 +64,13 @@ public sealed class AIDeploymentModelParameterViewModel [BindNever] public AIModelParameterKind Kind { get; set; } + /// + /// Gets or sets the optional trained feature this parameter depends on. When set, the editor only + /// shows the parameter while the matching feature is enabled. + /// + [BindNever] + public string RequiredFeature { get; set; } + /// /// Gets or sets every value registered for a choice parameter. /// @@ -258,6 +265,7 @@ public void MergeRegisteredCapabilities( row.DisplayName = descriptor.DisplayName?.Value ?? descriptor.Name; row.Description = descriptor.Description?.Value; row.Kind = descriptor.Kind; + row.RequiredFeature = descriptor.RequiredFeature; row.AvailableValues = [ .. descriptor.AllowedValues.Select(option => new SelectListItem(option.DisplayName?.Value ?? option.Value, option.Value)) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml index bfc74aa1..504b7bd7 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml @@ -30,7 +30,8 @@ name="SelectedFeatures" type="checkbox" value="@feature.Name" - class="form-check-input" + class="form-check-input model-feature-toggle" + data-feature-name="@feature.Name" @(isChecked ? "checked" : "") />
} - @if (Model.ModelParameters.Count > 0) + @{ + var standaloneCards = indexedParameters + .Where(card => string.IsNullOrWhiteSpace(card.Parameter.RequiredFeature)) + .ToList(); + } + + @if (standaloneCards.Count > 0) { - @for (var i = 0; i < Model.ModelParameters.Count; i++) + @foreach (var card in standaloneCards) { - var parameter = Model.ModelParameters[i]; - var bodyId = $"modelParameterBody_{parameter.ElementId}"; - var requiresFeature = !string.IsNullOrWhiteSpace(parameter.RequiredFeature); - var featureEnabled = !requiresFeature - || (Model.SelectedFeatures?.Contains(parameter.RequiredFeature, StringComparer.OrdinalIgnoreCase) ?? false); -
- -
- - - -
-
- @if (parameter.Kind == AIModelParameterKind.Choice && parameter.AvailableValues.Any()) - { -
-
- - -
Leave nothing selected to support every registered value.
-
-
- - -
Applied when an operator does not select a value.
-
-
- } - else - { -
-
- - -
- @if (parameter.Kind == AIModelParameterKind.Number || parameter.Kind == AIModelParameterKind.Integer) - { -
- - -
-
- - -
-
- - -
- } -
- } -
-
+ } }
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelParameterCard.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelParameterCard.cshtml new file mode 100644 index 00000000..bab3f9d7 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelParameterCard.cshtml @@ -0,0 +1,107 @@ +@using CrestApps.Core.AI.Models +@using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels +@model AIDeploymentModelParameterCardViewModel +@{ + var parameter = Model.Parameter; + var i = Model.Index; + var bodyId = $"modelParameterBody_{parameter.ElementId}"; +} +
+ +
+ + + +
+
+ @if (parameter.Kind == AIModelParameterKind.Choice && parameter.AvailableValues.Any()) + { +
+
+ + +
Leave nothing selected to support every registered value.
+
+
+ + +
Applied when an operator does not select a value.
+
+
+ } + else + { +
+
+ + +
+ @if (parameter.Kind == AIModelParameterKind.Number || parameter.Kind == AIModelParameterKind.Integer) + { +
+ + +
+
+ + +
+
+ + +
+ } +
+ } +
+
diff --git a/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs index fd939d4f..28501034 100644 --- a/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs @@ -86,6 +86,46 @@ public void GetCapabilities_WhenRegisteredDefaultIsNotSupported_ShouldClearTheDe Assert.Null(descriptor.DefaultValue); } + [Fact] + public void GetCapabilities_WhenParameterRequiresAFeatureTheDeploymentDoesNotDeclare_ShouldExcludeTheParameter() + { + // Arrange + var service = CreateService(out _); + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Features = [], + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AIModelParameterNames.ReasoningEffort] = new AIDeploymentModelParameter(), + }, + }); + + // Act + var descriptor = service.GetCapabilities(deployment).GetParameter(AIModelParameterNames.ReasoningEffort); + + // Assert + Assert.Null(descriptor); + } + + [Fact] + public void GetCapabilities_WhenParameterRequiresADeclaredFeature_ShouldExposeTheParameter() + { + // Arrange + var service = CreateService(out _); + var deployment = CreateDeployment(new AIDeploymentModelParameter()); + + // Act + var descriptor = service.GetCapabilities(deployment).GetParameter(AIModelParameterNames.ReasoningEffort); + + // Assert + Assert.NotNull(descriptor); + } + [Fact] public void GetCapabilities_WhenDeploymentOverridesDefault_ShouldUseTheOverride() { @@ -375,6 +415,7 @@ private static AIModelCapabilityOptions CreateOptions() { descriptor.Kind = reasoningEffort.Kind; descriptor.DefaultValue = reasoningEffort.DefaultValue; + descriptor.RequiredFeature = AIModelFeatureNames.Reasoning; descriptor.AllowedValues = reasoningEffort.AllowedValues; }); From 20ee1ecc81658b12f5d25d9db1057e0e43346c10 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 8 Aug 2026 02:59:26 +0300 Subject: [PATCH 6/6] Enforce AI deployment trained capabilities universally Add extensible model capability enforcement so unsupported request options never reach a provider, avoiding provider validation errors (e.g. HTTP 400). Framework: - Add shared ModelFeatureEnforcement helper that strips tools/ToolMode when toolCalling is not declared, removes a JSON response format when structuredOutputs is not declared, removes reasoning options when reasoning is not declared, removes the reasoning effort when the reasoningEffort parameter is not exposed, and coerces an unsupported effort to the deployment default (preserving other reasoning state). - Add CapabilityEnforcingChatClient and wire it into DefaultAIClientFactory as the terminal layer above the provider client so enforcement also runs for callers that resolve an IChatClient directly. Enforcement is opt-in to deployments that declare capability metadata; the capability service is optional so unconfigured hosts are unchanged. - Convert model parameter values written to AdditionalProperties to their typed primitive; parse integers through decimal for exact Int64 bounds, reject non-finite numbers, and skip values that cannot be represented. - Log the Azure ExtraHigh -> High reasoning-effort clamp instead of downgrading silently. - Replace the computerUse feature with videoOutput. UI: - Unify the Blazor and MVC deployment editors on bootstrap-select (1.2.1) and sort trained features alphabetically. Docs and tests updated; all tests pass with zero warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/AIModelFeatureNames.cs | 4 +- .../Models/AIModelParameterDescriptor.cs | 2 +- .../docs/changelog/1.1.0.md | 44 ++- .../docs/core/ai-model-capabilities.md | 14 +- .../Services/AzureOpenAICompletionClient.cs | 15 +- .../Capabilities/ModelFeatureEnforcement.cs | 125 +++++++ ...ModelFeaturesAICompletionServiceHandler.cs | 20 +- ...delParametersAICompletionServiceHandler.cs | 66 +++- .../ServiceCollectionExtensions.cs | 4 +- .../Services/CapabilityEnforcingChatClient.cs | 78 +++++ .../Services/DefaultAIClientFactory.cs | 18 + .../Components/App.razor | 3 + .../Forms/BootstrapMultiSelect.razor | 103 ++++++ .../Forms/ModelCapabilitiesEditor.razor | 32 +- .../wwwroot/js/bootstrap-select-interop.js | 36 ++ .../_DeploymentModelCapabilities.cshtml | 4 +- .../Framework/AI/AIModelCapabilityTests.cs | 219 +++++++++++- .../AI/CapabilityEnforcingChatClientTests.cs | 316 ++++++++++++++++++ ...FeaturesAICompletionServiceHandlerTests.cs | 72 +++- 19 files changed, 1102 insertions(+), 73 deletions(-) create mode 100644 src/Primitives/CrestApps.Core.AI/Capabilities/ModelFeatureEnforcement.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Services/CapabilityEnforcingChatClient.cs create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/BootstrapMultiSelect.razor create mode 100644 src/Startup/CrestApps.Core.Blazor.Web/wwwroot/js/bootstrap-select-interop.js create mode 100644 tests/CrestApps.Core.Tests/Framework/AI/CapabilityEnforcingChatClientTests.cs diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs index 31148092..a938d93e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelFeatureNames.cs @@ -52,7 +52,7 @@ public static class AIModelFeatureNames public const string VideoInput = "videoInput"; /// - /// The model can operate a computer or browser environment. + /// The model can generate video. /// - public const string ComputerUse = "computerUse"; + public const string VideoOutput = "videoOutput"; } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs index 3e9f1e1f..85fcb52f 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIModelParameterDescriptor.cs @@ -123,7 +123,7 @@ public bool IsValidValue(string value) case AIModelParameterKind.Integer: case AIModelParameterKind.Number: - if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) || !double.IsFinite(number)) { return false; } diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 6a3954e9..72d44106 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -32,9 +32,9 @@ guide. - adds `AddCoreAIModelCapabilities()`, chained automatically by `AddCoreAIServices()`, which registers the capability service, the completion handlers, the reasoning-effort binder, ten built-in **trained** features (`toolCalling`, `structuredOutputs`, `streaming`, `reasoning`, `imageInput`, - `imageOutput`, `audioInput`, `audioOutput`, `videoInput`, and `computerUse`), and the built-in + `imageOutput`, `audioInput`, `audioOutput`, `videoInput`, and `videoOutput`), and the built-in `reasoningEffort` parameter. Features model genuine trained model capabilities; provider-hosted - tools such as web search are intentionally not registered as features + tools such as web search or computer use are intentionally not registered as features - adds `AIModelFeatureDescriptor.EnabledByDefault` so a registered feature can be pre-selected on newly created deployments, and flags `toolCalling` and `streaming` as enabled by default - adds `AIModelParameterDescriptor.RequiredFeature` so a parameter can declare a dependency on a trained @@ -44,9 +44,24 @@ guide. authored. The deployment editors render a dependent parameter inline beneath its feature, only show it while the feature is enabled, and clear it when the feature is turned off, and the **Model parameters** heading only appears for parameters that are not linked to a feature - after the tool-adding handlers and, for deployments that declare capability metadata, removes tools - and `ToolMode` when `toolCalling` is not declared and removes a JSON response format when - `structuredOutputs` is not declared. Deployments without metadata stay unconstrained +- adds `ModelFeaturesAICompletionServiceHandler`, registered after the tool-adding handlers, which for + deployments that declare capability metadata removes tools and `ToolMode` when `toolCalling` is not + declared, removes a JSON response format when `structuredOutputs` is not declared, removes reasoning + options when `reasoning` is not declared, and when `reasoning` is declared removes the reasoning + effort if the `reasoningEffort` parameter is not exposed or coerces an unsupported effort to the + deployment default. Deployments without metadata stay unconstrained +- adds a `CapabilityEnforcingChatClient` that `IAIClientFactory` wraps as the terminal layer immediately + above the provider-facing client (below any pipeline middleware), so the same trained-feature + enforcement runs even when middleware adds unsupported options and when a caller resolves an + `IChatClient` and calls it directly outside the completion pipeline. The wrapper clones the request + options before removing unsupported tools, tool modes, JSON response formats, and reasoning options, + which prevents avoidable provider validation errors (for example HTTP 400) on the direct client path +- clears `ChatOptions.ToolMode` whenever `toolCalling` is not declared, even when no tools were + supplied, so an unsupported deployment never receives a tool mode on its own +- converts model parameter values written to `ChatOptions.AdditionalProperties` to their underlying + primitive (`Integer`, `Number`, or `Boolean`) using invariant parsing instead of always sending them + as strings, rejects non-finite numbers during validation, and skips values that cannot be represented + as the declared type instead of sending them as a mismatched string - adds `AIDeploymentModelMetadata` so a deployment declares the features and parameters its model exposes and can narrow the allowed values, default, or numeric bounds of a registered parameter. The metadata flows through configuration and recipes with no additional code because deployment @@ -62,7 +77,9 @@ guide. otherwise writes the value to `ChatOptions.AdditionalProperties` - adds `IAIModelParameterBinder` with a built-in reasoning-effort binder that sets `ChatOptions.Reasoning.Effort`, and updates `AzureOpenAICompletionClient` to translate the resolved - effort onto `ChatCompletionOptions.ReasoningEffortLevel` so both request paths behave the same + effort onto `ChatCompletionOptions.ReasoningEffortLevel` so both request paths behave the same. The + `ExtraHigh` effort is clamped to the highest level the Azure/OpenAI SDK exposes (`High`) and the + downgrade is logged rather than applied silently - adds a `ModelParameters` front-matter key to the markdown profile template parser, accepting `name=value` pairs separated by `;` or by a new line - adds `CompletionServiceConfigureContext.Deployment` so handlers can resolve the deployment that is @@ -70,17 +87,22 @@ guide. - updates `CrestApps.Core.Mvc.Web` with a reusable deployment capability editor and a metadata-driven model parameter editor wired into the AI deployment, AI profile, AI profile template, and chat interaction screens. Unsupported fields are hidden and disabled so they are never submitted. The - deployment editor groups features under a **Trained features** heading, pre-checks the default - features on new deployments, and renders the allowed-values selectors with the + deployment editor groups features under a **Trained features** heading, sorts the trained features + alphabetically, pre-checks the default features on new deployments, and renders the allowed-values + selectors with the [`@crestapps/bootstrap-select`](https://github.com/CrestApps/bootstrap-select) searchable multi-select. The AI profile, profile template, and chat interaction editors show the selected deployment's declared trained capabilities as read-only badges that update when the deployment changes - updates `CrestApps.Core.Blazor.Web` with the equivalent `ModelCapabilitiesEditor` and `ModelParametersEditor` components, which re-render when the selected deployment changes and prune values the new deployment does not support. The capabilities editor uses the **Trained features** - heading and pre-selects the default features on new deployments, and the parameters editor shows the - deployment's declared trained capabilities as read-only badges - + heading, sorts the trained features alphabetically, and pre-selects the default features on new + deployments, and the parameters editor shows the deployment's declared trained capabilities as + read-only badges. The allowed-values selectors use the same + [`@crestapps/bootstrap-select`](https://github.com/CrestApps/bootstrap-select) searchable multi-select + as the MVC editor through an isolated `BootstrapMultiSelect` component and JS interop module, so both + hosts present the same editing experience + ## Fixes - fixes post-session processing endlessly retrying and eventually failing when the AI returned a successful (HTTP 200) response that could not be parsed into structured task results. The no-tools structured output path now records a `Failed` result with a diagnostic message instead of silently returning no result, so these responses no longer exhaust all retry attempts. The unparseable-response case is now logged at `Warning` (including a preview of the raw AI response) instead of only at `Debug`, and the recorded task error message now explains that the AI produced no parseable result or there was no content to evaluate. diff --git a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md index bae2b31b..95a3e96c 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-model-capabilities.md @@ -59,11 +59,12 @@ You rarely need to call this directly — `AddCoreAIServices()` chains it automa | `audioInput` | `AIModelFeatureNames.AudioInput` | | The model accepts audio input. | | `audioOutput` | `AIModelFeatureNames.AudioOutput` | | The model produces audio output. | | `videoInput` | `AIModelFeatureNames.VideoInput` | | The model can understand video inputs. | -| `computerUse` | `AIModelFeatureNames.ComputerUse` | | The model can operate a computer or browser environment. | +| `videoOutput` | `AIModelFeatureNames.VideoOutput` | | The model can generate video. | These represent **trained capabilities** the underlying model was built with. Provider-hosted tools -(such as a web-search tool the provider runs on your behalf) are *not* modeled as features because -almost any tool-calling model can be handed such a tool — they are ordinary tools, not a trained +(such as a web-search tool the provider runs on your behalf, or a computer-use tool) are *not* modeled +as features because almost any tool-calling model can be handed such a tool — they are ordinary tools, +not a trained trait. Set `AIModelFeatureDescriptor.EnabledByDefault` when registering a feature to pre-select it on newly created deployments; existing deployments are never changed by this flag. @@ -364,9 +365,12 @@ Both sample hosts render the metadata rather than hardcoding options. In `CrestApps.Core.Mvc.Web` the server renders every registered parameter inside hidden, disabled wrappers together with a deployment-to-capability JSON map; a small script shows, enables, and filters the fields when the deployment selection changes. Disabled inputs are not posted, so an unsupported -value can never be submitted. The deployment editor's allowed-values selectors use the +value can never be submitted. Both the `CrestApps.Core.Mvc.Web` and `CrestApps.Core.Blazor.Web` +deployment editors render the allowed-values selectors with the [`@crestapps/bootstrap-select`](https://github.com/CrestApps/bootstrap-select) picker for a searchable -multi-select experience. In `CrestApps.Core.Blazor.Web` the components re-render reactively and prune +multi-select experience, so the two hosts present the same editing UI. In `CrestApps.Core.Blazor.Web` +the picker is wrapped in an isolated `BootstrapMultiSelect` component that initializes the plugin once +through a small JS interop module and reports selection changes back to Blazor, and the components prune values that the newly selected deployment does not support. :::note diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs index f6a0d36e..3b662ab1 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs @@ -445,7 +445,7 @@ private AzureOpenAIClient GetChatClient(AIProviderConnectionEntry connection) return optionsContext.SystemFunctions; } - private static ChatCompletionOptions GetOptions(AICompletionContext context, IEnumerable functions, Microsoft.Extensions.AI.ChatOptions resolvedOptions = null) + private ChatCompletionOptions GetOptions(AICompletionContext context, IEnumerable functions, Microsoft.Extensions.AI.ChatOptions resolvedOptions = null) { var chatOptions = new ChatCompletionOptions() { @@ -475,7 +475,7 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn } #pragma warning disable OPENAI001 // ChatCompletionOptions.ReasoningEffortLevel is an evaluation-only API in the OpenAI SDK. - private static void ApplyReasoningEffort(ChatCompletionOptions chatOptions, Microsoft.Extensions.AI.ChatOptions resolvedOptions) + private void ApplyReasoningEffort(ChatCompletionOptions chatOptions, Microsoft.Extensions.AI.ChatOptions resolvedOptions) { var effort = resolvedOptions?.Reasoning?.Effort; @@ -489,8 +489,19 @@ private static void ApplyReasoningEffort(ChatCompletionOptions chatOptions, Micr Microsoft.Extensions.AI.ReasoningEffort.None => ChatReasoningEffortLevel.Minimal, Microsoft.Extensions.AI.ReasoningEffort.Low => ChatReasoningEffortLevel.Low, Microsoft.Extensions.AI.ReasoningEffort.Medium => ChatReasoningEffortLevel.Medium, + Microsoft.Extensions.AI.ReasoningEffort.High => ChatReasoningEffortLevel.High, + // The Azure/OpenAI SDK does not expose a level above High, so ExtraHigh (and any future + // value) is clamped to the highest level the SDK supports. This is logged so the downgrade + // is observable rather than silent. _ => ChatReasoningEffortLevel.High, }; + + if (effort.Value is Microsoft.Extensions.AI.ReasoningEffort.ExtraHigh) + { + _logger.LogWarning( + "The reasoning effort '{Effort}' is not supported by the Azure OpenAI SDK and was clamped to '{Applied}'.", + effort.Value, ChatReasoningEffortLevel.High); + } } #pragma warning restore OPENAI001 diff --git a/src/Primitives/CrestApps.Core.AI/Capabilities/ModelFeatureEnforcement.cs b/src/Primitives/CrestApps.Core.AI/Capabilities/ModelFeatureEnforcement.cs new file mode 100644 index 00000000..14d45e35 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Capabilities/ModelFeatureEnforcement.cs @@ -0,0 +1,125 @@ +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Capabilities; + +/// +/// Provides the shared feature-enforcement logic that removes request options which depend on a +/// trained feature the deployment does not declare. The logic is used by both the completion +/// pipeline handler and the client-factory wrapper so that enforcement is identical on every path. +/// +internal static class ModelFeatureEnforcement +{ + /// + /// Removes or corrects the standard, provider-agnostic options that require a trained feature the + /// deployment does not declare (such as tools, a JSON response format, or reasoning), and coerces + /// the reasoning effort to a supported value when reasoning is declared but the requested effort + /// is not exposed. + /// + /// The chat options to sanitize. + /// The effective capabilities of the deployment. + /// The deployment name used when logging removed options. + /// The logger used to report removed options. + /// when at least one option was removed; otherwise . + public static bool Enforce( + ChatOptions options, + AIDeploymentCapabilities capabilities, + string deploymentName, + ILogger logger) + { + if (options is null || capabilities is null) + { + return false; + } + + var changed = false; + + if (!capabilities.SupportsFeature(AIModelFeatureNames.ToolCalling)) + { + if (options.Tools is { Count: > 0 }) + { + logger.LogWarning( + "Deployment '{Deployment}' does not declare the '{Feature}' feature. {Count} tool(s) were removed from the request.", + deploymentName, AIModelFeatureNames.ToolCalling, options.Tools.Count); + + options.Tools = null; + changed = true; + } + + // Clear the tool mode even when no tools were supplied so an unsupported deployment never + // receives a tool mode such as RequireAny, which some providers reject on its own. + if (options.ToolMode is not null) + { + options.ToolMode = null; + changed = true; + } + } + + if (!capabilities.SupportsFeature(AIModelFeatureNames.StructuredOutputs) && options.ResponseFormat is ChatResponseFormatJson) + { + logger.LogWarning( + "Deployment '{Deployment}' does not declare the '{Feature}' feature. The JSON response format was removed from the request.", + deploymentName, AIModelFeatureNames.StructuredOutputs); + + options.ResponseFormat = null; + changed = true; + } + + if (!capabilities.SupportsFeature(AIModelFeatureNames.Reasoning)) + { + // The deployment is not trained to reason, so no reasoning option may be sent to the + // provider. Removing it prevents providers from rejecting the request outright. + if (options.Reasoning is not null) + { + logger.LogWarning( + "Deployment '{Deployment}' does not declare the '{Feature}' feature. The reasoning options were removed from the request.", + deploymentName, AIModelFeatureNames.Reasoning); + + options.Reasoning = null; + changed = true; + } + } + else if (options.Reasoning?.Effort is ReasoningEffort effort) + { + // Reasoning is supported, but the selected effort must still be exposed by the deployment + // and be one of the values it allows; otherwise the provider may reject the request. Only + // the effort is adjusted so any other reasoning state (such as Output) is preserved. + var descriptor = capabilities.GetParameter(AIModelParameterNames.ReasoningEffort); + + if (descriptor is null) + { + logger.LogWarning( + "Deployment '{Deployment}' does not expose the '{Parameter}' parameter. The reasoning effort '{Effort}' was removed from the request.", + deploymentName, AIModelParameterNames.ReasoningEffort, effort); + + options.Reasoning.Effort = null; + changed = true; + } + else if (!descriptor.IsValidValue(effort.ToString())) + { + if (!string.IsNullOrWhiteSpace(descriptor.DefaultValue) && + Enum.TryParse(descriptor.DefaultValue, ignoreCase: true, out var fallback)) + { + logger.LogWarning( + "Deployment '{Deployment}' does not support the reasoning effort '{Effort}'. The deployment default '{Default}' is used instead.", + deploymentName, effort, fallback); + + options.Reasoning.Effort = fallback; + } + else + { + logger.LogWarning( + "Deployment '{Deployment}' does not support the reasoning effort '{Effort}'. The reasoning effort was removed from the request.", + deploymentName, effort); + + options.Reasoning.Effort = null; + } + + changed = true; + } + } + + return changed; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs index eb74851a..890a0d2f 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/ModelFeaturesAICompletionServiceHandler.cs @@ -1,7 +1,6 @@ using CrestApps.Core.AI.Capabilities; using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Models; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Handlers; @@ -45,24 +44,7 @@ public Task ConfigureAsync(CompletionServiceConfigureContext context, Cancellati var capabilities = _capabilityService.GetCapabilities(context.Deployment); - if (!capabilities.SupportsFeature(AIModelFeatureNames.ToolCalling) && context.ChatOptions.Tools is { Count: > 0 }) - { - _logger.LogWarning( - "Deployment '{Deployment}' does not declare the '{Feature}' feature. {Count} tool(s) were removed from the request.", - context.DeploymentName, AIModelFeatureNames.ToolCalling, context.ChatOptions.Tools.Count); - - context.ChatOptions.Tools = null; - context.ChatOptions.ToolMode = null; - } - - if (!capabilities.SupportsFeature(AIModelFeatureNames.StructuredOutputs) && context.ChatOptions.ResponseFormat is ChatResponseFormatJson) - { - _logger.LogWarning( - "Deployment '{Deployment}' does not declare the '{Feature}' feature. The JSON response format was removed from the request.", - context.DeploymentName, AIModelFeatureNames.StructuredOutputs); - - context.ChatOptions.ResponseFormat = null; - } + ModelFeatureEnforcement.Enforce(context.ChatOptions, capabilities, context.DeploymentName, _logger); return Task.CompletedTask; } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs index 45e5763d..b7199069 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/ModelParametersAICompletionServiceHandler.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CrestApps.Core.AI.Capabilities; using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Models; @@ -65,8 +66,15 @@ public async Task ConfigureAsync(CompletionServiceConfigureContext context, Canc if (binder is null) { - context.ChatOptions.AdditionalProperties ??= []; - context.ChatOptions.AdditionalProperties[descriptor.Name] = value; + if (TryConvertValue(descriptor, value, out var converted)) + { + context.ChatOptions.AdditionalProperties ??= []; + context.ChatOptions.AdditionalProperties[descriptor.Name] = converted; + } + else + { + _logger.LogWarning("The value '{Value}' could not be converted for the model parameter '{Parameter}'. The parameter was skipped.", value, descriptor.Name); + } continue; } @@ -75,6 +83,60 @@ public async Task ConfigureAsync(CompletionServiceConfigureContext context, Canc } } + private static bool TryConvertValue(AIModelParameterDescriptor descriptor, string value, out object converted) + { + // The value has already been validated against the descriptor, so conversion is expected to + // succeed. Converting to the underlying primitive avoids sending numeric and Boolean values as + // quoted strings, which some providers reject. When conversion cannot produce the typed value, + // the parameter is skipped rather than sent as a mismatched string. + switch (descriptor.Kind) + { + case AIModelParameterKind.Integer: + // Parse through decimal so the Int64 bounds are checked exactly. Using double would + // round long.MaxValue up to 2^63, allowing an out-of-range value to overflow the cast. + if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var integral) && + integral == Math.Truncate(integral) && + integral >= long.MinValue && + integral <= long.MaxValue) + { + converted = (long)integral; + + return true; + } + + break; + + case AIModelParameterKind.Number: + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number)) + { + converted = number; + + return true; + } + + break; + + case AIModelParameterKind.Boolean: + if (bool.TryParse(value, out var boolean)) + { + converted = boolean; + + return true; + } + + break; + + default: + converted = value; + + return true; + } + + converted = null; + + return false; + } + private string GetValue(AICompletionContext completionContext, AIModelParameterDescriptor descriptor) { if (!completionContext.ModelParameters.TryGetValue(descriptor.Name, out var value) || string.IsNullOrWhiteSpace(value)) diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 5b34daec..5a35fb5b 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -259,9 +259,9 @@ public static IServiceCollection AddCoreAIModelCapabilities(this IServiceCollect feature.Description = new LocalizedString(AIModelFeatureNames.VideoInput, "The model can understand video inputs."); feature.Order = 90; }) - .AddAIModelFeature(AIModelFeatureNames.ComputerUse, new LocalizedString(AIModelFeatureNames.ComputerUse, "Computer use"), feature => + .AddAIModelFeature(AIModelFeatureNames.VideoOutput, new LocalizedString(AIModelFeatureNames.VideoOutput, "Video output"), feature => { - feature.Description = new LocalizedString(AIModelFeatureNames.ComputerUse, "The model can operate a computer or browser environment."); + feature.Description = new LocalizedString(AIModelFeatureNames.VideoOutput, "The model can generate video."); feature.Order = 100; }); diff --git a/src/Primitives/CrestApps.Core.AI/Services/CapabilityEnforcingChatClient.cs b/src/Primitives/CrestApps.Core.AI/Services/CapabilityEnforcingChatClient.cs new file mode 100644 index 00000000..7f940e40 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/CapabilityEnforcingChatClient.cs @@ -0,0 +1,78 @@ +using CrestApps.Core.AI.Capabilities; +using CrestApps.Core.AI.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Services; + +/// +/// A that enforces the trained features declared by a deployment +/// on every request, including requests issued directly against a client resolved from +/// outside the completion pipeline. Options that depend on an +/// undeclared feature (for example tools or a JSON response format) are removed before the request +/// reaches the provider, which prevents avoidable provider validation errors such as HTTP 400. +/// Enforcement is opt-in: only deployments that declare capability metadata are constrained. +/// +internal sealed class CapabilityEnforcingChatClient : DelegatingChatClient +{ + private readonly AIDeployment _deployment; + private readonly IAIModelCapabilityService _capabilityService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The inner chat client that performs the provider request. + /// The deployment whose declared capabilities are enforced. + /// The capability service used to resolve the deployment capabilities. + /// The logger used to report removed options. + public CapabilityEnforcingChatClient( + IChatClient innerClient, + AIDeployment deployment, + IAIModelCapabilityService capabilityService, + ILogger logger) + : base(innerClient) + { + _deployment = deployment; + _capabilityService = capabilityService; + _logger = logger; + } + + /// + public override Task GetResponseAsync( + IEnumerable messages, + ChatOptions options = null, + CancellationToken cancellationToken = default) + { + return base.GetResponseAsync(messages, Enforce(options), cancellationToken); + } + + /// + public override IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions options = null, + CancellationToken cancellationToken = default) + { + return base.GetStreamingResponseAsync(messages, Enforce(options), cancellationToken); + } + + private ChatOptions Enforce(ChatOptions options) + { + // Enforcement is opt-in: a deployment without declared capability metadata is left untouched + // so existing configurations keep working exactly as before. + if (options is null || !_deployment.TryGet(out _)) + { + return options; + } + + var capabilities = _capabilityService.GetCapabilities(_deployment); + + // Clone before mutating so a caller that reuses the same options instance across requests is + // never affected by the enforcement performed for this deployment. + var enforced = options.Clone(); + + return ModelFeatureEnforcement.Enforce(enforced, capabilities, _deployment.ModelName, _logger) + ? enforced + : options; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs index 13f31665..b038530e 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.AI.Capabilities; using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Models; @@ -74,6 +75,23 @@ public async ValueTask CreateChatClientAsync(AIDeployment deploymen _serviceProvider, _serviceProvider.GetRequiredService>()); + // Enforce the deployment's declared trained features as the terminal layer, immediately above + // the provider-facing client and below any pipeline middleware supplied through + // configurePipeline. This guarantees unsupported options are removed even when middleware adds + // them, and it also covers callers that resolve the client and call it directly outside the + // completion pipeline. The capability service is optional so hosts that do not register the + // capability services keep working unchanged. + var capabilityService = _serviceProvider.GetService(); + + if (capabilityService is not null) + { + client = new CapabilityEnforcingChatClient( + client, + deployment, + capabilityService, + _serviceProvider.GetRequiredService>()); + } + return BuildChatClient(client, configurePipeline); } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/App.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/App.razor index 8ed49f4c..60c4c768 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/App.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/App.razor @@ -8,6 +8,7 @@ + @@ -15,6 +16,8 @@ + + diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/BootstrapMultiSelect.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/BootstrapMultiSelect.razor new file mode 100644 index 00000000..f1c21704 --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/BootstrapMultiSelect.razor @@ -0,0 +1,103 @@ +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject IJSRuntime JS + + + +@code { + private bool _initialized; + + /// + /// Gets or sets the DOM identifier assigned to the rendered select element. + /// + [Parameter] + public string ElementId { get; set; } + + /// + /// Gets or sets the placeholder shown when no value is selected. + /// + [Parameter] + public string Title { get; set; } + + /// + /// Gets or sets the selectable options. + /// + [Parameter] + public IReadOnlyList Options { get; set; } = []; + + /// + /// Gets or sets the values that are selected when the component is first rendered. + /// + [Parameter] + public IReadOnlyCollection SelectedValues { get; set; } = []; + + /// + /// Gets or sets the callback invoked when the selection changes. + /// + [Parameter] + public EventCallback> SelectedValuesChanged { get; set; } + + // The bootstrap-select plugin replaces the native control with its own DOM. Re-rendering the + // element from Blazor would clobber that DOM, so the component renders once and then stops + // rendering, letting the plugin own the element for its lifetime. + protected override bool ShouldRender() + => !_initialized; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || _initialized) + { + return; + } + + _initialized = true; + await JS.InvokeVoidAsync("crestappsBootstrapSelect.init", ElementId); + } + + private async Task OnNativeChange(ChangeEventArgs args) + { + var values = args.Value switch + { + string[] selected => selected, + string single => string.IsNullOrEmpty(single) ? [] : [single], + _ => (IReadOnlyList)[], + }; + + await SelectedValuesChanged.InvokeAsync(values); + } + + /// + public async ValueTask DisposeAsync() + { + if (_initialized) + { + try + { + await JS.InvokeVoidAsync("crestappsBootstrapSelect.dispose", ElementId); + } + catch (JSDisconnectedException) + { + } + } + } + + /// + /// Represents a single selectable option. + /// + /// The option value. + /// The option display text. + public sealed record BootstrapMultiSelectOption(string Value, string Text); +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor index 55f95af6..0c2f5d6f 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Forms/ModelCapabilitiesEditor.razor @@ -19,11 +19,11 @@

Capabilities the underlying model was trained with. Only declared features are sent to the provider.

- @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "Trained Features")) + @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "Trained Features").OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) {
@group.Key
- @foreach (var feature in group) + @foreach (var feature in group.OrderBy(feature => feature.DisplayName?.Value ?? feature.Name, StringComparer.OrdinalIgnoreCase)) { var featureName = feature.Name; var dependentParameters = Model.ModelParameters @@ -147,18 +147,11 @@
- @foreach (var option in parameter.AvailableValues) - { - var optionValue = option.Value; -
- - -
- } +
Leave nothing selected to support every registered value.
@@ -210,15 +203,8 @@ parameter.IsSupported = args.Value is true; } - private static void ToggleAllowedValue(AIDeploymentModelParameterViewModel parameter, string optionValue, ChangeEventArgs args) + private static void SetAllowedValues(AIDeploymentModelParameterViewModel parameter, IReadOnlyList values) { - if (args.Value is true) - { - parameter.SelectedAllowedValues.Add(optionValue); - } - else - { - parameter.SelectedAllowedValues.Remove(optionValue); - } + parameter.SelectedAllowedValues = new HashSet(values, StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/wwwroot/js/bootstrap-select-interop.js b/src/Startup/CrestApps.Core.Blazor.Web/wwwroot/js/bootstrap-select-interop.js new file mode 100644 index 00000000..4c4058ba --- /dev/null +++ b/src/Startup/CrestApps.Core.Blazor.Web/wwwroot/js/bootstrap-select-interop.js @@ -0,0 +1,36 @@ +(function () { + "use strict"; + + window.crestappsBootstrapSelect = window.crestappsBootstrapSelect || { + init: function (elementId) { + var element = document.getElementById(elementId); + + if (!element || typeof window.Selectpicker !== "function" || element.dataset.selectpicker === "true") { + return; + } + + element.dataset.selectpicker = "true"; + element.crestappsSelectpicker = new window.Selectpicker(element, { liveSearch: true }); + }, + dispose: function (elementId) { + var element = document.getElementById(elementId); + + if (!element) { + return; + } + + var instance = element.crestappsSelectpicker; + + if (instance && typeof instance.destroy === "function") { + try { + instance.destroy(); + } catch (e) { + // Ignore teardown errors so a disconnected circuit never surfaces an exception. + } + } + + delete element.crestappsSelectpicker; + delete element.dataset.selectpicker; + } + }; +})(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml index 8527d3db..a91ec656 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_DeploymentModelCapabilities.cshtml @@ -27,11 +27,11 @@

Capabilities the underlying model was trained with. Only declared features are sent to the provider.

- @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "Trained Features")) + @foreach (var group in Model.AvailableFeatures.GroupBy(feature => feature.Category ?? "Trained Features").OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) {
@group.Key
- @foreach (var feature in group) + @foreach (var feature in group.OrderBy(feature => feature.DisplayName, StringComparer.OrdinalIgnoreCase)) { var isChecked = FeatureEnabled(feature.Name); var dependentCards = indexedParameters diff --git a/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs index 28501034..6a1fb87e 100644 --- a/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/AI/AIModelCapabilityTests.cs @@ -6,6 +6,7 @@ using CrestApps.Core.AI.Services; using Microsoft.Extensions.AI; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -218,6 +219,23 @@ public void IsValidValue_ForIntegerParameter_ShouldRejectFractionalValues() Assert.False(descriptor.IsValidValue("12.5")); } + [Theory] + [InlineData("NaN")] + [InlineData("Infinity")] + [InlineData("-Infinity")] + public void IsValidValue_ForNumberParameter_ShouldRejectNonFiniteValues(string value) + { + // Arrange + var descriptor = new AIModelParameterDescriptor + { + Name = "sampling", + Kind = AIModelParameterKind.Number, + }; + + // Act & Assert + Assert.False(descriptor.IsValidValue(value)); + } + [Fact] public async Task ConfigureAsync_WhenDeploymentExposesReasoningEffort_ShouldApplyTheSelectedValue() { @@ -270,6 +288,36 @@ public async Task ConfigureAsync_WhenTheSelectedValueIsNotSupported_ShouldFallBa Assert.Equal(ReasoningEffort.Low, context.ChatOptions.Reasoning?.Effort); } + [Fact] + public async Task ConfigureAsync_WhenTheSelectedValueIsNotSupported_ShouldLogWarning() + { + // Arrange + var logger = new Mock>(); + var handler = CreateHandler(logger: logger); + var deployment = CreateDeployment(new AIDeploymentModelParameter + { + AllowedValues = ["Low", "Medium"], + DefaultValue = "Low", + }); + var context = CreateConfigureContext(deployment, ("reasoningEffort", "High")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert +#pragma warning disable CA1873 + logger.Verify( + value => value.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => + state.ToString().Contains(AIModelParameterNames.ReasoningEffort, StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + Times.Once); +#pragma warning restore CA1873 + } + [Fact] public async Task ConfigureAsync_WhenNoValueIsSelected_ShouldApplyTheDeploymentDefault() { @@ -326,6 +374,171 @@ public async Task ConfigureAsync_WhenNoBinderIsRegistered_ShouldWriteToAdditiona Assert.Equal("high", context.ChatOptions.AdditionalProperties["verbosity"]); } + [Theory] + [InlineData(AIModelParameterKind.Integer, "12", 12L)] + [InlineData(AIModelParameterKind.Number, "0.5", 0.5d)] + [InlineData(AIModelParameterKind.Boolean, "true", true)] + public async Task ConfigureAsync_WhenNoBinderIsRegistered_ShouldWriteTypedPrimitiveToAdditionalProperties( + AIModelParameterKind kind, + string storedValue, + object expected) + { + // Arrange + var options = new AIModelCapabilityOptions(); + options.AddParameter("customParameter", new LocalizedString("Custom", "Custom"), descriptor => + { + descriptor.Kind = kind; + descriptor.Minimum = 0; + descriptor.Maximum = 100; + }); + + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["customParameter"] = new AIDeploymentModelParameter(), + }, + }); + + var handler = CreateHandler(options); + var context = CreateConfigureContext(deployment, ("customParameter", storedValue)); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(context.ChatOptions.AdditionalProperties); + var actual = context.ChatOptions.AdditionalProperties["customParameter"]; + Assert.Equal(expected, actual); + Assert.IsType(expected.GetType(), actual); + } + + [Fact] + public async Task ConfigureAsync_WhenNoBinderIsRegistered_ShouldConvertExponentIntegerToLong() + { + // Arrange + var options = new AIModelCapabilityOptions(); + options.AddParameter("customParameter", new LocalizedString("Custom", "Custom"), descriptor => + { + descriptor.Kind = AIModelParameterKind.Integer; + }); + + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["customParameter"] = new AIDeploymentModelParameter(), + }, + }); + + var handler = CreateHandler(options); + var context = CreateConfigureContext(deployment, ("customParameter", "1e3")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(context.ChatOptions.AdditionalProperties); + Assert.Equal(1000L, context.ChatOptions.AdditionalProperties["customParameter"]); + } + + [Fact] + public async Task ConfigureAsync_WhenNoBinderIsRegistered_ShouldConvertMaxInt64WithoutOverflow() + { + // Arrange + var options = new AIModelCapabilityOptions(); + options.AddParameter("customParameter", new LocalizedString("Custom", "Custom"), descriptor => + { + descriptor.Kind = AIModelParameterKind.Integer; + }); + + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["customParameter"] = new AIDeploymentModelParameter(), + }, + }); + + var handler = CreateHandler(options); + + // long.MaxValue rounds up to 2^63 as a double, so a double-based cast would overflow. The value + // just above it must be skipped while the exact maximum converts correctly. + var context = CreateConfigureContext(deployment, ("customParameter", "9223372036854775807")); + var overflowContext = CreateConfigureContext(deployment, ("customParameter", "9223372036854775808")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + await handler.ConfigureAsync(overflowContext, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(long.MaxValue, context.ChatOptions.AdditionalProperties["customParameter"]); + Assert.Null(overflowContext.ChatOptions.AdditionalProperties); + } + + [Fact] + public async Task ConfigureAsync_WhenNoBinderIsRegisteredAndValueCannotConvert_ShouldSkipAndLogWarning() + { + // Arrange + var options = new AIModelCapabilityOptions(); + options.AddParameter("customParameter", new LocalizedString("Custom", "Custom"), descriptor => + { + descriptor.Kind = AIModelParameterKind.Integer; + }); + + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["customParameter"] = new AIDeploymentModelParameter(), + }, + }); + + var logger = new Mock>(); + var handler = CreateHandler(options, logger); + + // A value larger than long.MaxValue passes the descriptor's numeric validation but cannot be + // represented as a 64-bit integer, so it must be skipped rather than sent as a string. + var context = CreateConfigureContext(deployment, ("customParameter", "1e30")); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(context.ChatOptions.AdditionalProperties); +#pragma warning disable CA1873 + logger.Verify( + value => value.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => + state.ToString().Contains("customParameter", StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + Times.Once); +#pragma warning restore CA1873 + } + [Fact] public void ApplyModelParameters_ShouldCopyTheStoredValuesIntoTheCompletionContext() { @@ -429,13 +642,15 @@ private static DefaultAIModelCapabilityService CreateService(out AIModelCapabili return new DefaultAIModelCapabilityService(Options.Create(options), Mock.Of()); } - private static ModelParametersAICompletionServiceHandler CreateHandler(AIModelCapabilityOptions options = null) + private static ModelParametersAICompletionServiceHandler CreateHandler( + AIModelCapabilityOptions options = null, + Mock> logger = null) { var service = new DefaultAIModelCapabilityService(Options.Create(options ?? CreateOptions()), Mock.Of()); return new ModelParametersAICompletionServiceHandler( service, [new ReasoningEffortModelParameterBinder()], - NullLogger.Instance); + logger?.Object ?? NullLogger.Instance); } } diff --git a/tests/CrestApps.Core.Tests/Framework/AI/CapabilityEnforcingChatClientTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/CapabilityEnforcingChatClientTests.cs new file mode 100644 index 00000000..9b3e4726 --- /dev/null +++ b/tests/CrestApps.Core.Tests/Framework/AI/CapabilityEnforcingChatClientTests.cs @@ -0,0 +1,316 @@ +using CrestApps.Core.AI; +using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CrestApps.Core.Tests.Framework.AI; + +public sealed class CapabilityEnforcingChatClientTests +{ + [Fact] + public async Task GetResponseAsync_WhenDeploymentDoesNotDeclareToolCalling_ShouldRemoveToolsBeforeCallingInner() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateDeployment(AIModelFeatureNames.StructuredOutputs); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Tools = [new TestAIFunction("sample-tool")], + ToolMode = ChatToolMode.RequireAny, + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(inner.LastOptions.Tools); + Assert.Null(inner.LastOptions.ToolMode); + } + + [Fact] + public async Task GetResponseAsync_ShouldNotMutateTheCallerOptions() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateDeployment(AIModelFeatureNames.StructuredOutputs); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Tools = [new TestAIFunction("sample-tool")], + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(options.Tools); + Assert.Single(options.Tools); + Assert.NotSame(options, inner.LastOptions); + } + + [Fact] + public async Task GetResponseAsync_WhenDeploymentDeclaresToolCalling_ShouldKeepToolsAndReuseTheSameOptions() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateDeployment(AIModelFeatureNames.ToolCalling); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Tools = [new TestAIFunction("sample-tool")], + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(inner.LastOptions.Tools); + Assert.Single(inner.LastOptions.Tools); + Assert.Same(options, inner.LastOptions); + } + + [Fact] + public async Task GetResponseAsync_WhenDeploymentDeclaresNoMetadata_ShouldNotEnforce() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Tools = [new TestAIFunction("sample-tool")], + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(inner.LastOptions.Tools); + Assert.Same(options, inner.LastOptions); + } + + [Fact] + public async Task GetStreamingResponseAsync_WhenDeploymentDoesNotDeclareStructuredOutputs_ShouldRemoveJsonResponseFormat() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateDeployment(AIModelFeatureNames.ToolCalling); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + ResponseFormat = ChatResponseFormat.Json, + }; + + // Act + await foreach (var _ in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken)) + { + } + + // Assert + Assert.Null(inner.LastOptions.ResponseFormat); + } + + [Fact] + public async Task GetResponseAsync_WhenDeploymentDoesNotDeclareReasoning_ShouldRemoveReasoning() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateDeployment(AIModelFeatureNames.ToolCalling); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Effort = ReasoningEffort.High }, + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(inner.LastOptions.Reasoning); + Assert.NotNull(options.Reasoning); + } + + [Fact] + public async Task GetResponseAsync_WhenReasoningEffortIsNotSupported_ShouldCoerceToDeploymentDefault() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateReasoningDeployment(); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Effort = ReasoningEffort.ExtraHigh }, + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ReasoningEffort.Medium, inner.LastOptions.Reasoning.Effort); + Assert.Equal(ReasoningEffort.ExtraHigh, options.Reasoning.Effort); + } + + [Fact] + public async Task GetResponseAsync_WhenReasoningEffortIsSupported_ShouldKeepReasoning() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateReasoningDeployment(); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Effort = ReasoningEffort.High }, + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ReasoningEffort.High, inner.LastOptions.Reasoning.Effort); + Assert.Same(options, inner.LastOptions); + } + + [Fact] + public async Task GetResponseAsync_WhenReasoningEffortParameterIsNotExposed_ShouldRemoveEffortButKeepReasoning() + { + // Arrange + var inner = new CapturingChatClient(); + var deployment = CreateDeployment(AIModelFeatureNames.Reasoning); + var client = CreateClient(inner, deployment); + var options = new ChatOptions + { + Reasoning = new ReasoningOptions { Effort = ReasoningEffort.High }, + }; + + // Act + await client.GetResponseAsync([new ChatMessage(ChatRole.User, "Hello")], options, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(inner.LastOptions.Reasoning); + Assert.Null(inner.LastOptions.Reasoning.Effort); + Assert.Equal(ReasoningEffort.High, options.Reasoning.Effort); + } + + private static CapabilityEnforcingChatClient CreateClient(IChatClient inner, AIDeployment deployment) + { + var options = new AIModelCapabilityOptions(); + options.AddFeature(AIModelFeatureNames.ToolCalling, new LocalizedString("Tool calling", "Tool calling")); + options.AddFeature(AIModelFeatureNames.StructuredOutputs, new LocalizedString("Structured outputs", "Structured outputs")); + options.AddFeature(AIModelFeatureNames.Reasoning, new LocalizedString("Reasoning", "Reasoning")); + options.AddParameter(AIModelParameterNames.ReasoningEffort, new LocalizedString("Reasoning effort", "Reasoning effort"), parameter => + { + parameter.Kind = AIModelParameterKind.Choice; + parameter.RequiredFeature = AIModelFeatureNames.Reasoning; + parameter.DefaultValue = nameof(ReasoningEffort.Medium); + parameter.AllowedValues = + [ + new AIModelParameterOption { Value = nameof(ReasoningEffort.Low), DisplayName = new LocalizedString("Low", "Low") }, + new AIModelParameterOption { Value = nameof(ReasoningEffort.Medium), DisplayName = new LocalizedString("Medium", "Medium") }, + new AIModelParameterOption { Value = nameof(ReasoningEffort.High), DisplayName = new LocalizedString("High", "High") }, + ]; + }); + + var service = new DefaultAIModelCapabilityService(Options.Create(options), Mock.Of()); + + return new CapabilityEnforcingChatClient(inner, deployment, service, NullLogger.Instance); + } + + private static AIDeployment CreateDeployment(params string[] features) + { + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Features = features, + }); + + return deployment; + } + + private static AIDeployment CreateReasoningDeployment() + { + var deployment = new AIDeployment + { + Name = "gpt-5", + }; + + deployment.Put(new AIDeploymentModelMetadata + { + Features = [AIModelFeatureNames.Reasoning], + Parameters = new(StringComparer.OrdinalIgnoreCase) + { + [AIModelParameterNames.ReasoningEffort] = new AIDeploymentModelParameter(), + }, + }); + + return deployment; + } + + private sealed class CapturingChatClient : IChatClient + { + public ChatOptions LastOptions { get; private set; } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions options = null, + CancellationToken cancellationToken = default) + { + LastOptions = options; + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok"))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + LastOptions = options; + + yield return new ChatResponseUpdate(ChatRole.Assistant, "ok"); + + await Task.CompletedTask; + } + + public object GetService(Type serviceType, object serviceKey = null) + => null; + + public void Dispose() + { + } + } + + private sealed class TestAIFunction : AIFunction + { + public TestAIFunction(string name) + { + Name = name; + } + + public override string Name { get; } + + public override string Description => Name; + + public override System.Text.Json.JsonElement JsonSchema + => System.Text.Json.JsonSerializer.Deserialize("{}"); + + protected override ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + return new ValueTask(Name); + } + } +} diff --git a/tests/CrestApps.Core.Tests/Framework/AI/ModelFeaturesAICompletionServiceHandlerTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/ModelFeaturesAICompletionServiceHandlerTests.cs index 476f2034..f62a0ab2 100644 --- a/tests/CrestApps.Core.Tests/Framework/AI/ModelFeaturesAICompletionServiceHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/AI/ModelFeaturesAICompletionServiceHandlerTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -66,6 +67,39 @@ public async Task ConfigureAsync_WhenDeploymentDoesNotDeclareToolCalling_ShouldR Assert.Null(context.ChatOptions.ToolMode); } + [Fact] + public async Task ConfigureAsync_WhenDeploymentDoesNotDeclareToolCalling_ShouldLogWarning() + { + // Arrange + var logger = new Mock>(); + var handler = CreateHandler(logger); + var deployment = CreateDeployment(AIModelFeatureNames.StructuredOutputs); + var context = CreateContext(deployment, tools: true); + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + VerifyWarningLogged(logger, AIModelFeatureNames.ToolCalling); + } + + [Fact] + public async Task ConfigureAsync_WhenDeploymentDoesNotDeclareToolCalling_ShouldClearToolModeEvenWithoutTools() + { + // Arrange + var handler = CreateHandler(); + var deployment = CreateDeployment(AIModelFeatureNames.StructuredOutputs); + var context = CreateContext(deployment, tools: false); + context.ChatOptions.ToolMode = ChatToolMode.RequireAny; + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(context.ChatOptions.Tools); + Assert.Null(context.ChatOptions.ToolMode); + } + [Fact] public async Task ConfigureAsync_WhenDeploymentDoesNotDeclareStructuredOutputs_ShouldRemoveJsonResponseFormat() { @@ -82,6 +116,23 @@ public async Task ConfigureAsync_WhenDeploymentDoesNotDeclareStructuredOutputs_S Assert.Null(context.ChatOptions.ResponseFormat); } + [Fact] + public async Task ConfigureAsync_WhenDeploymentDoesNotDeclareStructuredOutputs_ShouldLogWarning() + { + // Arrange + var logger = new Mock>(); + var handler = CreateHandler(logger); + var deployment = CreateDeployment(AIModelFeatureNames.ToolCalling); + var context = CreateContext(deployment, tools: false); + context.ChatOptions.ResponseFormat = ChatResponseFormat.Json; + + // Act + await handler.ConfigureAsync(context, TestContext.Current.CancellationToken); + + // Assert + VerifyWarningLogged(logger, AIModelFeatureNames.StructuredOutputs); + } + [Fact] public async Task ConfigureAsync_WhenDeploymentDeclaresStructuredOutputs_ShouldKeepJsonResponseFormat() { @@ -113,7 +164,9 @@ public void AddCoreAIModelCapabilities_ShouldRegisterTheTrainedFeatureSet() Assert.Contains(AIModelFeatureNames.ImageInput, options.Features.Keys); Assert.Contains(AIModelFeatureNames.ImageOutput, options.Features.Keys); Assert.Contains(AIModelFeatureNames.VideoInput, options.Features.Keys); + Assert.Contains(AIModelFeatureNames.VideoOutput, options.Features.Keys); Assert.DoesNotContain("webSearch", options.Features.Keys); + Assert.DoesNotContain("computerUse", options.Features.Keys); Assert.True(options.Features[AIModelFeatureNames.ToolCalling].EnabledByDefault); Assert.True(options.Features[AIModelFeatureNames.Streaming].EnabledByDefault); Assert.False(options.Features[AIModelFeatureNames.Reasoning].EnabledByDefault); @@ -173,7 +226,7 @@ private static CompletionServiceConfigureContext CreateContext(AIDeployment depl }; } - private static ModelFeaturesAICompletionServiceHandler CreateHandler() + private static ModelFeaturesAICompletionServiceHandler CreateHandler(Mock> logger = null) { var options = new AIModelCapabilityOptions(); options.AddFeature(AIModelFeatureNames.ToolCalling, new LocalizedString("Tool calling", "Tool calling")); @@ -181,7 +234,22 @@ private static ModelFeaturesAICompletionServiceHandler CreateHandler() var service = new DefaultAIModelCapabilityService(Options.Create(options), Mock.Of()); - return new ModelFeaturesAICompletionServiceHandler(service, NullLogger.Instance); + return new ModelFeaturesAICompletionServiceHandler(service, logger?.Object ?? NullLogger.Instance); + } + + private static void VerifyWarningLogged(Mock> logger, string feature) + { +#pragma warning disable CA1873 + logger.Verify( + value => value.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => + state.ToString().Contains(feature, StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + Times.Once); +#pragma warning restore CA1873 } private sealed class TestAIFunction : AIFunction