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,