From 9d7c4fcf951b5f9cce97e4f790414d21eb08df4c Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 9 Oct 2024 14:34:41 -0400 Subject: [PATCH] Enable bidirectional adapters between SK interfaces and Microsoft.Extensions.AI This is part 1 of replatforming Semantic Kernel on top of Microsoft.Extensions.AI, making it possible to use IChatCompletionService instances as IChatClient instances, and vice versa, and similarly for IEmbeddingGenerationService and IEmbeddingGenerator. The next step after this is refactoring the connectors to use M.E.AI internally where relevant. --- dotnet/Directory.Packages.props | 12 +- .../src/Diagnostics/ModelDiagnostics.cs | 6 +- .../Functions/KernelFunction.cs | 69 ++ .../Functions/KernelPlugin.cs | 12 + .../SemanticKernel.Abstractions.csproj | 2 + .../SemanticKernel.Core.csproj | 2 + .../ServiceConversionExtensions.cs | 790 ++++++++++++++++++ .../AI/ServiceConversionExtensionsTests.cs | 20 + 8 files changed, 905 insertions(+), 8 deletions(-) create mode 100644 dotnet/src/SemanticKernel.Core/ServiceConversionExtensions.cs create mode 100644 dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1f9410ac40af0..b203add85c836 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -23,7 +23,7 @@ - + @@ -33,29 +33,31 @@ - + - + + + - + - + diff --git a/dotnet/src/InternalUtilities/src/Diagnostics/ModelDiagnostics.cs b/dotnet/src/InternalUtilities/src/Diagnostics/ModelDiagnostics.cs index e091939f0cf35..f29cb9bec40fe 100644 --- a/dotnet/src/InternalUtilities/src/Diagnostics/ModelDiagnostics.cs +++ b/dotnet/src/InternalUtilities/src/Diagnostics/ModelDiagnostics.cs @@ -24,7 +24,7 @@ namespace Microsoft.SemanticKernel.Diagnostics; [ExcludeFromCodeCoverage] internal static class ModelDiagnostics { - private static readonly string s_namespace = typeof(ModelDiagnostics).Namespace!; + internal static readonly string s_namespace = typeof(ModelDiagnostics).Namespace!; private static readonly ActivitySource s_activitySource = new(s_namespace); private const string EnableDiagnosticsSwitch = "Microsoft.SemanticKernel.Experimental.GenAI.EnableOTelDiagnostics"; @@ -32,8 +32,8 @@ internal static class ModelDiagnostics private const string EnableDiagnosticsEnvVar = "SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS"; private const string EnableSensitiveEventsEnvVar = "SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE"; - private static readonly bool s_enableDiagnostics = AppContextSwitchHelper.GetConfigValue(EnableDiagnosticsSwitch, EnableDiagnosticsEnvVar); - private static readonly bool s_enableSensitiveEvents = AppContextSwitchHelper.GetConfigValue(EnableSensitiveEventsSwitch, EnableSensitiveEventsEnvVar); + internal static readonly bool s_enableDiagnostics = AppContextSwitchHelper.GetConfigValue(EnableDiagnosticsSwitch, EnableDiagnosticsEnvVar); + internal static readonly bool s_enableSensitiveEvents = AppContextSwitchHelper.GetConfigValue(EnableSensitiveEventsSwitch, EnableSensitiveEventsEnvVar); /// /// Start a text completion activity for a given model. diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs index 149dbf108ecea..53a4967ec96db 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunction.cs @@ -5,11 +5,14 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.SemanticKernel.Diagnostics; @@ -441,4 +444,70 @@ private static void HandleException( throw kernelEx; } } + + /// Creates an for this . + /// + /// The instance to pass to the when it's invoked as part of the 's invocation. + /// + /// An instance of that, when invoked, will in turn invoke the current . + [Experimental("SKEXP0001")] + public AIFunction AsAIFunction(Kernel? kernel = null) + { + return new KernelAIFunction(this, kernel); + } + + /// An wrapper around a . + private sealed class KernelAIFunction : AIFunction + { + private readonly KernelFunction _kernelFunction; + private readonly Kernel? _kernel; + + public KernelAIFunction(KernelFunction kernelFunction, Kernel? kernel) + { + this._kernelFunction = kernelFunction; + this._kernel = kernel; + + string name = string.IsNullOrWhiteSpace(kernelFunction.PluginName) ? + kernelFunction.Name : + $"{kernelFunction.PluginName}_{kernelFunction.Name}"; + + this.Metadata = new AIFunctionMetadata(name) + { + Description = kernelFunction.Description, + + Parameters = kernelFunction.Metadata.Parameters.Select(p => new AIFunctionParameterMetadata(p.Name) + { + Description = p.Description, + ParameterType = p.ParameterType, + IsRequired = p.IsRequired, + HasDefaultValue = p.DefaultValue is not null, + DefaultValue = p.DefaultValue, + Schema = p.Schema?.RootElement, + }).ToList(), + + ReturnParameter = new AIFunctionReturnParameterMetadata() + { + Description = kernelFunction.Metadata.ReturnParameter.Description, + ParameterType = kernelFunction.Metadata.ReturnParameter.ParameterType, + Schema = kernelFunction.Metadata.ReturnParameter.Schema?.RootElement, + }, + }; + } + + public override AIFunctionMetadata Metadata { get; } + + protected override async Task InvokeCoreAsync(IEnumerable> arguments, CancellationToken cancellationToken) + { + Verify.NotNull(arguments); + + KernelArguments args = []; + foreach (var argument in arguments) + { + args[argument.Key] = argument.Value; + } + + var functionResult = await this._kernelFunction.InvokeAsync(this._kernel ?? new(), args, cancellationToken).ConfigureAwait(false); + return functionResult.Value is object value ? JsonSerializer.SerializeToElement(value) : null; + } + } } diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelPlugin.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelPlugin.cs index 9ba7e2db8d75f..30227246b21b2 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelPlugin.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelPlugin.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Microsoft.Extensions.AI; #pragma warning disable CA1716 // Identifiers should not match keywords @@ -92,6 +93,17 @@ public IList GetFunctionsMetadata() /// public abstract IEnumerator GetEnumerator(); + /// Produces an for every in this plugin. + /// An enumerable of instances, one for each in this plugin. + [Experimental("SKEXP0001")] + public IEnumerable AsAIFunctions() + { + foreach (KernelFunction function in this) + { + yield return function.AsAIFunction(); + } + } + /// IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); diff --git a/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj b/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj index 2c2ed1b1aad19..c41f61310f1bd 100644 --- a/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj +++ b/dotnet/src/SemanticKernel.Abstractions/SemanticKernel.Abstractions.csproj @@ -5,6 +5,7 @@ Microsoft.SemanticKernel net8.0;netstandard2.0 $(NoWarn);SKEXP0001 + $(NoWarn);NU5104 true @@ -24,6 +25,7 @@ + diff --git a/dotnet/src/SemanticKernel.Core/SemanticKernel.Core.csproj b/dotnet/src/SemanticKernel.Core/SemanticKernel.Core.csproj index 1e4a9d5fc52a3..94ab31495e40d 100644 --- a/dotnet/src/SemanticKernel.Core/SemanticKernel.Core.csproj +++ b/dotnet/src/SemanticKernel.Core/SemanticKernel.Core.csproj @@ -8,6 +8,7 @@ true true $(NoWarn);SKEXP0001 + $(NoWarn);NU5104 true @@ -33,6 +34,7 @@ + diff --git a/dotnet/src/SemanticKernel.Core/ServiceConversionExtensions.cs b/dotnet/src/SemanticKernel.Core/ServiceConversionExtensions.cs new file mode 100644 index 0000000000000..167d5ab3ddafd --- /dev/null +++ b/dotnet/src/SemanticKernel.Core/ServiceConversionExtensions.cs @@ -0,0 +1,790 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Diagnostics; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel; + +// Eventually, the intent is that the Microsoft.Extensions.AI interfaces will be the primary interfaces used by Semantic Kernel, +// with helper extension methods providing the same general surface area as is provided on the SK interfaces today, e.g. +// public static async Task> GetChatMessageContentsAsync( +// this IChatClient chatClient, +// ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default); +// In the meantime, the methods in this type provide bidirectional conversions between the two sets of interfaces. + +/// +/// Provides extension methods for converting between Microsoft.SemanticKernel types and core .NET types. +/// +[Experimental("SKEXP0001")] +public static class ServiceConversionExtensions +{ + /// Creates an for the specified . + /// The chat completion service to be represented as a chat client. + /// + /// The . If the is an , the + /// will be returned. Otherwise, a new will be created that wraps . + /// + public static IChatClient AsChatClient(this IChatCompletionService service) + { + Verify.NotNull(service); + + return service is IChatClient chatClient ? + chatClient : + new ChatCompletionServiceChatClient(service); + } + + // TODO: We can also add AsChatClient overloads for ITextToImageService, ITextToAudioService, IImageToTextService, and IAudioToTextService. + // All of these operate on the same input and output content types already supported by the more general IChatClient interface. + // Decide if we want to. + + /// Creates an for the specified . + /// The chat client to be represented as a chat completion service. + /// An optional that can be used to resolve services to use in the instance. + /// + /// The . If is an , will + /// be returned. Otherwise, a new will be created that wraps . + /// + public static IChatCompletionService AsChatCompletionService(this IChatClient client, IServiceProvider? serviceProvider = null) + { + Verify.NotNull(client); + + return client is IChatCompletionService chatCompletionService ? + chatCompletionService : + new ChatClientChatCompletionService(client, serviceProvider); + } + + /// Creates an for the specified . + /// The embedding generation service to be represented as an embedding generator. + /// + /// The . If the is an , + /// the will be returned. Otherwise, a new will be created that wraps the . + /// + public static IEmbeddingGenerator> AsEmbeddingGenerator( + this IEmbeddingGenerationService service) + where TEmbedding : unmanaged + { + Verify.NotNull(service); + + return service is IEmbeddingGenerator> embeddingGenerator ? + embeddingGenerator : + new EmbeddingGenerationServiceEmbeddingGenerator(service); + } + + /// Creates an for the specified . + /// The embedding generator to be represented as an embedding generation service. + /// An optional that can be used to resolve services to use in the instance. + /// + /// The . If the is an , + /// the will be returned. Otherwise, a new will be created that wraps the . + /// + public static IEmbeddingGenerationService AsEmbeddingGenerationService( + this IEmbeddingGenerator> generator, + IServiceProvider? serviceProvider = null) + where TEmbedding : unmanaged + { + Verify.NotNull(generator); + + return generator is IEmbeddingGenerationService service ? + service : + new EmbeddingGeneratorEmbeddingGenerationService(generator, serviceProvider); + } + + /// Provides an implementation of around an . + private sealed class EmbeddingGenerationServiceEmbeddingGenerator : IEmbeddingGenerator> + where TEmbedding : unmanaged + { + /// The wrapped + private readonly IEmbeddingGenerationService _service; + + /// Initializes the for . + public EmbeddingGenerationServiceEmbeddingGenerator(IEmbeddingGenerationService service) + { + this._service = service; + this.Metadata = new EmbeddingGeneratorMetadata( + service.GetType().Name, + service.GetEndpoint() is string endpoint ? new Uri(endpoint) : null, + service.GetModelId()); + } + + /// + public EmbeddingGeneratorMetadata Metadata { get; } + + /// + public void Dispose() + { + (this._service as IDisposable)?.Dispose(); + } + + /// + public async Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + IList> result = await this._service.GenerateEmbeddingsAsync(values.ToList(), kernel: null, cancellationToken).ConfigureAwait(false); + return new(result.Select(e => new Embedding(e))); + } + + /// + public TService? GetService(object? key = null) where TService : class + { + return + typeof(TService) == typeof(IEmbeddingGenerator>) ? (TService)(object)this : + this._service as TService; + } + } + + /// Provides an implementation of around an . + private sealed class EmbeddingGeneratorEmbeddingGenerationService : IEmbeddingGenerationService + where TEmbedding : unmanaged + { + /// The wrapped + private readonly IEmbeddingGenerator> _generator; + + /// Initializes the for . + public EmbeddingGeneratorEmbeddingGenerationService( + IEmbeddingGenerator> generator, IServiceProvider? serviceProvider) + { + // IEmbeddingGenerationService include telemetry, logging, etc. + // IEmbeddingGenerator may not. Add in such capabilities as needed to fulfill the expectations. + if (generator.GetService() is null) + { + if (ModelDiagnostics.s_enableDiagnostics && + generator.GetService>>() is null) + { + generator = new OpenTelemetryEmbeddingGenerator>(generator, ModelDiagnostics.s_namespace); + } + + if (generator.GetService>>() is null && + serviceProvider?.GetService>>>() is ILogger logger) + { + generator = new LoggingEmbeddingGenerator>(generator, logger); + } + } + + // Store the generator. + this._generator = generator; + + // Initialize the attributes. + var attrs = new Dictionary(); + this.Attributes = new ReadOnlyDictionary(attrs); + + var metadata = generator.Metadata; + if (metadata.ProviderUri is not null) + { + attrs[AIServiceExtensions.EndpointKey] = metadata.ProviderUri.ToString(); + } + if (metadata.ModelId is not null) + { + attrs[AIServiceExtensions.ModelIdKey] = metadata.ModelId; + } + } + + /// + public IReadOnlyDictionary Attributes { get; } + + /// + public async Task>> GenerateEmbeddingsAsync(IList data, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + Verify.NotNull(data); + + var embeddings = await this._generator.GenerateAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false); + + return embeddings.Select(e => e.Vector).ToList(); + } + } + + /// Provides an implementation of around an arbitrary . + private sealed class ChatCompletionServiceChatClient : IChatClient + { + /// The wrapped . + private readonly IChatCompletionService _chatCompletionService; + + /// Initializes the for . + public ChatCompletionServiceChatClient(IChatCompletionService chatCompletionService) + { + Verify.NotNull(chatCompletionService); + + this._chatCompletionService = chatCompletionService; + + this.Metadata = new ChatClientMetadata( + chatCompletionService.GetType().Name, + chatCompletionService.GetEndpoint() is string endpoint ? new Uri(endpoint) : null, + chatCompletionService.GetModelId()); + } + + /// + public ChatClientMetadata Metadata { get; } + + /// + public async Task CompleteAsync(IList chatMessages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Verify.NotNull(chatMessages); + + var response = await this._chatCompletionService.GetChatMessageContentAsync( + new ChatHistory(chatMessages.Select(ToChatMessageContent)), + ToPromptExecutionSettings(options), + kernel: null, + cancellationToken).ConfigureAwait(false); + + return new(ToChatMessage(response)) + { + ModelId = response.ModelId, + RawRepresentation = response.InnerContent, + }; + } + + /// + public async IAsyncEnumerable CompleteStreamingAsync(IList chatMessages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(chatMessages); + + await foreach (var update in this._chatCompletionService.GetStreamingChatMessageContentsAsync( + new ChatHistory(chatMessages.Select(ToChatMessageContent)), + ToPromptExecutionSettings(options), + kernel: null, + cancellationToken).ConfigureAwait(false)) + { + yield return ToStreamingChatCompletionUpdate(update); + } + } + + /// + public void Dispose() + { + (this._chatCompletionService as IDisposable)?.Dispose(); + } + + /// + public TService? GetService(object? key = null) where TService : class + { + return + typeof(TService) == typeof(IChatClient) ? (TService)(object)this : + this._chatCompletionService as TService; + } + } + + /// Provides an implementation of around an . + private sealed class ChatClientChatCompletionService : IChatCompletionService + { + /// The wrapped . + private readonly IChatClient _chatClient; + + /// Initializes the for . + public ChatClientChatCompletionService(IChatClient chatClient, IServiceProvider? serviceProvider) + { + Verify.NotNull(chatClient); + + // IChatCompletionServices include telemetry, logging, automatic function calling, etc. + // IChatClients are more composable and may not. Heuristically add in such capabilities as + // needed to fulfill the expectations. + if (chatClient.GetService() is null) + { + if (ModelDiagnostics.s_enableDiagnostics && + chatClient.GetService() is null) + { + chatClient = new OpenTelemetryChatClient(chatClient, ModelDiagnostics.s_namespace) + { + EnableSensitiveData = ModelDiagnostics.s_enableSensitiveEvents, + }; + } + + if (chatClient.GetService() is null && + serviceProvider?.GetService>() is ILogger logger) + { + chatClient = new LoggingChatClient(chatClient, logger); + } + + if (chatClient.GetService() is null) + { + chatClient = new FunctionInvokingChatClient(chatClient); + } + } + + // Store the client. + this._chatClient = chatClient; + + // Initialize the attributes. + var attrs = new Dictionary(); + this.Attributes = new ReadOnlyDictionary(attrs); + + var metadata = chatClient.Metadata; + if (metadata.ProviderUri is not null) + { + attrs[AIServiceExtensions.EndpointKey] = metadata.ProviderUri.ToString(); + } + if (metadata.ModelId is not null) + { + attrs[AIServiceExtensions.ModelIdKey] = metadata.ModelId; + } + } + + /// + public IReadOnlyDictionary Attributes { get; } + + /// + public async Task> GetChatMessageContentsAsync( + ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default) + { + Verify.NotNull(chatHistory); + + var completion = await this._chatClient.CompleteAsync( + chatHistory.Select(ToChatMessage).ToList(), + ToChatOptions(executionSettings, kernel), + cancellationToken).ConfigureAwait(false); + + return completion.Choices.Select(ToChatMessageContent).ToList(); + } + + /// + public async IAsyncEnumerable GetStreamingChatMessageContentsAsync( + ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Verify.NotNull(chatHistory); + + await foreach (var update in this._chatClient.CompleteStreamingAsync( + chatHistory.Select(ToChatMessage).ToList(), + ToChatOptions(executionSettings, kernel), + cancellationToken).ConfigureAwait(false)) + { + yield return ToStreamingChatMessageContent(update); + } + } + } + + /// Converts a pair of and to a . + private static ChatOptions? ToChatOptions(PromptExecutionSettings? settings, Kernel? kernel) + { + if (settings is null && kernel is null) + { + return null; + } + + ChatOptions options = new(); + + if (settings is not null) + { + if (settings.GetType() != typeof(PromptExecutionSettings)) + { + // If the settings are of a derived type, serialize it and deserialize it as the base in order to try + // to get the derived strongly-typed properties to show up in the loosely-typed properties dictionary. + settings = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings)); + } + + if (settings!.ExtensionData is IDictionary extensionData) + { + foreach (var entry in extensionData) + { + if (entry.Key.Equals("temperature", StringComparison.OrdinalIgnoreCase) && + entry.Value is float temperature) + { + options.Temperature = temperature; + } + else if (entry.Key.Equals("top_p", StringComparison.OrdinalIgnoreCase) && + entry.Value is float top_p) + { + options.TopP = top_p; + } + else if (entry.Key.Equals("max_tokens", StringComparison.OrdinalIgnoreCase) && + entry.Value is int maxTokens) + { + options.MaxOutputTokens = maxTokens; + } + else if (entry.Key.Equals("stop_sequences", StringComparison.OrdinalIgnoreCase) && + entry.Value is IList stopSequences) + { + options.StopSequences = stopSequences; + } + else if (entry.Key.Equals("frequency_penalty", StringComparison.OrdinalIgnoreCase) && + entry.Value is float frequencyPenalty) + { + options.FrequencyPenalty = frequencyPenalty; + } + else if (entry.Key.Equals("presence_penalty", StringComparison.OrdinalIgnoreCase) && + entry.Value is float presence_penalty) + { + options.PresencePenalty = presence_penalty; + } + else if (entry.Key.Equals("response_format", StringComparison.OrdinalIgnoreCase) && + entry.Value is { } responseFormat) + { + options.ResponseFormat = responseFormat switch + { + "text" => ChatResponseFormat.Text, + "json" => ChatResponseFormat.Json, + JsonElement e => ChatResponseFormat.ForJsonSchema(e.ToString()), + _ => null, + }; + } + else + { + (options.AdditionalProperties ??= [])[entry.Key] = entry.Value; + } + } + } + + if (settings.FunctionChoiceBehavior?.GetConfiguration(new([]) { Kernel = kernel }).Functions is { Count: > 0 } functions) + { + options.ToolMode = settings.FunctionChoiceBehavior is RequiredFunctionChoiceBehavior ? ChatToolMode.RequireAny : ChatToolMode.Auto; + options.Tools = functions.Select(f => f.AsAIFunction(kernel)).Cast().ToList(); + } + } + + return options; + } + + /// Converts a to a . + private static PromptExecutionSettings? ToPromptExecutionSettings(ChatOptions? options) + { + if (options is null) + { + return null; + } + + PromptExecutionSettings settings = new() + { + ExtensionData = new Dictionary(StringComparer.OrdinalIgnoreCase) + }; + + // Transfer over all strongly-typed members of ChatOptions. We do not know the exact name the derived PromptExecutionSettings + // will pick for these options, so we just use the most common choice for each. + + if (options.Temperature is not null) + { + settings.ExtensionData["temperature"] = options.Temperature.Value; + } + + if (options.MaxOutputTokens is not null) + { + settings.ExtensionData["max_tokens"] = options.MaxOutputTokens.Value; + } + + if (options.FrequencyPenalty is not null) + { + settings.ExtensionData["frequency_penalty"] = options.FrequencyPenalty.Value; + } + + if (options.PresencePenalty is not null) + { + settings.ExtensionData["presence_penalty"] = options.PresencePenalty.Value; + } + + if (options.StopSequences is not null) + { + settings.ExtensionData["stop_sequences"] = options.StopSequences; + } + + if (options.TopP is not null) + { + settings.ExtensionData["top_p"] = options.TopP.Value; + } + + if (options.ResponseFormat is not null) + { + if (options.ResponseFormat is ChatResponseFormatText) + { + settings.ExtensionData["response_format"] = "text"; + } + else if (options.ResponseFormat is ChatResponseFormatJson json) + { + // There's no established way to pass response format with a schema via PromptExecutionSettings. + // For now, we'll use a JsonElement as the value. + settings.ExtensionData["response_format"] = json.Schema is not null ? + JsonSerializer.Deserialize(json.Schema) : + "json"; + } + } + + // Transfer over loosely-typed members of ChatOptions. + + if (options.AdditionalProperties is not null) + { + foreach (var kvp in options.AdditionalProperties) + { + if (kvp.Value is not null) + { + settings.ExtensionData[kvp.Key] = kvp.Value; + } + } + } + + // Transfer over tools. For IChatClient, we do not want automatic invocation, as that's a concern left up to + // components like FunctionInvocationChatClient. As such, based on the tool mode, we map to the appropriate + // FunctionChoiceBehavior, but always with autoInvoke: false. + + if (options.Tools is { Count: > 0 }) + { + var functions = options.Tools.OfType().Select(f => new AIFunctionKernelFunction(f)); + settings.FunctionChoiceBehavior = + options.ToolMode is null or AutoChatToolMode ? FunctionChoiceBehavior.Auto(functions, autoInvoke: false) : + options.ToolMode is RequiredChatToolMode { RequiredFunctionName: null } ? FunctionChoiceBehavior.Required(functions, autoInvoke: false) : + options.ToolMode is RequiredChatToolMode { RequiredFunctionName: string functionName } ? FunctionChoiceBehavior.Required(functions.Where(f => f.Name == functionName), autoInvoke: false) : + null; + } + + return settings; + } + + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + private static ChatMessage ToChatMessage(ChatMessageContent content) + { + ChatMessage message = new(); + + foreach (var item in content.Items) + { + AIContent? aiContent = null; + switch (item) + { + case Microsoft.SemanticKernel.TextContent tc: + aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); + break; + + case Microsoft.SemanticKernel.ImageContent ic: + aiContent = + ic.DataUri is not null ? new Microsoft.Extensions.AI.ImageContent(ic.DataUri, ic.MimeType) : + ic.Uri is not null ? new Microsoft.Extensions.AI.ImageContent(ic.Uri, ic.MimeType) : + null; + break; + + case Microsoft.SemanticKernel.AudioContent ac: + aiContent = + ac.DataUri is not null ? new Microsoft.Extensions.AI.AudioContent(ac.DataUri, ac.MimeType) : + ac.Uri is not null ? new Microsoft.Extensions.AI.AudioContent(ac.Uri, ac.MimeType) : + null; + break; + + case Microsoft.SemanticKernel.BinaryContent bc: + aiContent = + bc.DataUri is not null ? new Microsoft.Extensions.AI.DataContent(bc.DataUri, bc.MimeType) : + bc.Uri is not null ? new Microsoft.Extensions.AI.DataContent(bc.Uri, bc.MimeType) : + null; + break; + + case Microsoft.SemanticKernel.FunctionCallContent fcc: + aiContent = new Microsoft.Extensions.AI.FunctionCallContent(fcc.Id ?? string.Empty, fcc.FunctionName, fcc.Arguments); + break; + + case Microsoft.SemanticKernel.FunctionResultContent frc: + aiContent = new Microsoft.Extensions.AI.FunctionResultContent(frc.CallId ?? string.Empty, frc.FunctionName ?? string.Empty, frc.Result); + break; + } + + if (aiContent is not null) + { + aiContent.ModelId = content.ModelId; + aiContent.RawRepresentation = content.InnerContent; + aiContent.AdditionalProperties = content.Metadata is not null ? new(content.Metadata) : null; + + message.Contents.Add(aiContent); + } + } + + return message; + } + + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + private static ChatMessageContent ToChatMessageContent(ChatMessage message) + { + ChatMessageContent result = new() + { + InnerContent = message, + AuthorName = message.AuthorName, + Role = new AuthorRole(message.Role.Value), + Metadata = message.AdditionalProperties + }; + + foreach (AIContent content in message.Contents) + { + KernelContent? resultContent = null; + switch (content) + { + case Microsoft.Extensions.AI.TextContent tc: + resultContent = new Microsoft.SemanticKernel.TextContent(tc.Text); + break; + + case Microsoft.Extensions.AI.ImageContent ic: + resultContent = ic.ContainsData ? + new Microsoft.SemanticKernel.ImageContent(ic.Uri) : + new Microsoft.SemanticKernel.ImageContent(new Uri(ic.Uri)); + break; + + case Microsoft.Extensions.AI.AudioContent ac: + resultContent = ac.ContainsData ? + new Microsoft.SemanticKernel.AudioContent(ac.Uri) : + new Microsoft.SemanticKernel.AudioContent(new Uri(ac.Uri)); + break; + + case Microsoft.Extensions.AI.DataContent dc: + resultContent = dc.ContainsData ? + new Microsoft.SemanticKernel.BinaryContent(dc.Uri) : + new Microsoft.SemanticKernel.BinaryContent(new Uri(dc.Uri)); + break; + + case Microsoft.Extensions.AI.FunctionCallContent fcc: + resultContent = new Microsoft.SemanticKernel.FunctionCallContent(fcc.Name, null, fcc.CallId, fcc.Arguments is not null ? new(fcc.Arguments) : null); + break; + + case Microsoft.Extensions.AI.FunctionResultContent frc: + resultContent = new Microsoft.SemanticKernel.FunctionResultContent(frc.Name, null, frc.CallId, frc.Result); + break; + } + + if (resultContent is not null) + { + resultContent.ModelId = content.ModelId; + resultContent.Metadata = content.AdditionalProperties; + resultContent.InnerContent = content.RawRepresentation; + + result.Items.Add(resultContent); + } + } + + return result; + } + + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + private static StreamingChatCompletionUpdate ToStreamingChatCompletionUpdate(StreamingChatMessageContent content) + { + StreamingChatCompletionUpdate update = new() + { + AdditionalProperties = content.Metadata is not null ? new AdditionalPropertiesDictionary(content.Metadata) : null, + AuthorName = content.AuthorName, + ChoiceIndex = content.ChoiceIndex, + Role = content.Role is not null ? new ChatRole(content.Role.Value.Label) : null, + }; + + foreach (var item in content.Items) + { + AIContent? aiContent = null; + switch (item) + { + case Microsoft.SemanticKernel.StreamingTextContent tc: + aiContent = new Microsoft.Extensions.AI.TextContent(tc.Text); + break; + + case Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent fcc: + aiContent = new Microsoft.Extensions.AI.FunctionCallContent( + fcc.CallId ?? string.Empty, + fcc.Name ?? string.Empty, + fcc.Arguments is not null ? JsonSerializer.Deserialize>(fcc.Arguments) : null); + break; + } + + if (aiContent is not null) + { + aiContent.ModelId = content.ModelId; + aiContent.RawRepresentation = content; + + update.Contents.Add(aiContent); + } + } + + return update; + } + + /// Converts a to a . + /// This conversion should not be necessary once SK eventually adopts the shared content types. + private static StreamingChatMessageContent ToStreamingChatMessageContent(StreamingChatCompletionUpdate update) + { + StreamingChatMessageContent content = new( + update.Role is not null ? new AuthorRole(update.Role.Value.Value) : null, + null, + update.RawRepresentation, + update.ChoiceIndex, + metadata: update.AdditionalProperties); + + foreach (AIContent item in update.Contents) + { + StreamingKernelContent? resultContent = + item is Microsoft.Extensions.AI.TextContent tc ? new Microsoft.SemanticKernel.StreamingTextContent(tc.Text) : + item is Microsoft.Extensions.AI.FunctionCallContent fcc ? new Microsoft.SemanticKernel.StreamingFunctionCallUpdateContent(fcc.CallId, fcc.Name, fcc.Arguments is not null ? JsonSerializer.Serialize(fcc.Arguments) : null) : + null; + + if (resultContent is not null) + { + resultContent.ModelId = item.ModelId; + content.Items.Add(resultContent); + } + } + + return content; + } + + /// Provides a that wraps an . + /// + /// The implementation should largely be unused, other than for its . The implementation of + /// only manufactures these to pass along to the underlying + /// with autoInvoke:false, which means the + /// implementation shouldn't be invoking these functions at all. As such, the and + /// methods both unconditionally throw, even though they could be implemented. + /// + private sealed class AIFunctionKernelFunction : KernelFunction + { + private readonly AIFunction _aiFunction; + + public AIFunctionKernelFunction(AIFunction aiFunction) : + base(aiFunction.Metadata.Name, + aiFunction.Metadata.Description, + aiFunction.Metadata.Parameters.Select(p => new KernelParameterMetadata(p.Name) + { + Description = p.Description, + DefaultValue = p.DefaultValue, + IsRequired = p.IsRequired, + ParameterType = p.ParameterType, + Schema = + p.Schema is JsonElement je ? new KernelJsonSchema(je) : + p.Schema is string s ? new KernelJsonSchema(JsonSerializer.Deserialize(s)) : + null, + }).ToList(), + new KernelReturnParameterMetadata() + { + Description = aiFunction.Metadata.ReturnParameter.Description, + ParameterType = aiFunction.Metadata.ReturnParameter.ParameterType, + Schema = + aiFunction.Metadata.ReturnParameter.Schema is JsonElement je ? new KernelJsonSchema(je) : + aiFunction.Metadata.ReturnParameter.Schema is string s ? new KernelJsonSchema(JsonSerializer.Deserialize(s)) : + null, + }) + { + this._aiFunction = aiFunction; + } + + private AIFunctionKernelFunction(AIFunctionKernelFunction other, string pluginName) : + base(other.Name, pluginName, other.Description, other.Metadata.Parameters, other.Metadata.ReturnParameter) + { + this._aiFunction = other._aiFunction; + } + + public override KernelFunction Clone(string pluginName) + { + Verify.NotNullOrWhiteSpace(pluginName); + return new AIFunctionKernelFunction(this, pluginName); + } + + protected override ValueTask InvokeCoreAsync(Kernel kernel, KernelArguments arguments, CancellationToken cancellationToken) + { + // This should never be invoked, as instances are always passed with autoInvoke:false. + throw new NotSupportedException(); + } + + protected override IAsyncEnumerable InvokeStreamingCoreAsync(Kernel kernel, KernelArguments arguments, CancellationToken cancellationToken) + { + // This should never be invoked, as instances are always passed with autoInvoke:false. + throw new NotSupportedException(); + } + } +} diff --git a/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs new file mode 100644 index 0000000000000..042a97d8ecb05 --- /dev/null +++ b/dotnet/src/SemanticKernel.UnitTests/AI/ServiceConversionExtensionsTests.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.SemanticKernel; +using Xunit; + +namespace SemanticKernel.UnitTests.AI; + +public class ServiceConversionExtensionsTests +{ + [Fact] + public void InvalidArgumentsThrow() + { + Assert.Throws("service", () => ServiceConversionExtensions.AsChatClient(null!)); + Assert.Throws("client", () => ServiceConversionExtensions.AsChatCompletionService(null!)); + + Assert.Throws("service", () => ServiceConversionExtensions.AsEmbeddingGenerator(null!)); + Assert.Throws("generator", () => ServiceConversionExtensions.AsEmbeddingGenerationService(null!)); + } +}