diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index a032edea6116..ab1574e945e6 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -5,9 +5,9 @@ true - + - + @@ -91,7 +91,7 @@ - + @@ -106,7 +106,7 @@ - + diff --git a/dotnet/samples/Concepts/FunctionCalling/AzureAIInference_FunctionCalling.cs b/dotnet/samples/Concepts/FunctionCalling/AzureAIInference_FunctionCalling.cs new file mode 100644 index 000000000000..6315b9c9c88e --- /dev/null +++ b/dotnet/samples/Concepts/FunctionCalling/AzureAIInference_FunctionCalling.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; + +namespace FunctionCalling; +public class AzureAIInference_FunctionCalling : BaseTest +{ + private readonly LoggingHandler _handler; + private readonly HttpClient _httpClient; + private bool _isDisposed; + + public AzureAIInference_FunctionCalling(ITestOutputHelper output) : base(output) + { + // Create a logging handler to output HTTP requests and responses + this._handler = new LoggingHandler(new HttpClientHandler(), this.Output); + this._httpClient = new(this._handler); + } + + /// + /// This example demonstrates usage of that advertises all kernel functions to the AI model. + /// + [Fact] + public async Task FunctionCallingAsync() + { + var kernel = CreateKernel(); + AzureAIInferencePromptExecutionSettings settings = new() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + Console.WriteLine(await kernel.InvokePromptAsync("Given the current time of day and weather, what is the likely color of the sky in Boston?", new(settings))); + } + + /// + /// This example demonstrates usage of that advertises all kernel functions to the AI model. + /// + [Fact] + public async Task FunctionCallingWithPromptExecutionSettingsAsync() + { + var kernel = CreateKernel(); + PromptExecutionSettings settings = new() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }; + Console.WriteLine(await kernel.InvokePromptAsync("Given the current time of day and weather, what is the likely color of the sky in Boston?", new(settings))); + } + + protected override void Dispose(bool disposing) + { + if (!this._isDisposed) + { + if (disposing) + { + this._handler.Dispose(); + this._httpClient.Dispose(); + } + + this._isDisposed = true; + } + base.Dispose(disposing); + } + + private Kernel CreateKernel() + { + // Create kernel + var kernel = Kernel.CreateBuilder() + .AddAzureAIInferenceChatCompletion( + modelId: TestConfiguration.AzureAIInference.ChatModelId, + endpoint: new Uri(TestConfiguration.AzureAIInference.Endpoint), + apiKey: TestConfiguration.AzureAIInference.ApiKey, + httpClient: this._httpClient) + .Build(); + + // Add a plugin with some helper functions we want to allow the model to call. + kernel.ImportPluginFromFunctions("HelperFunctions", + [ + kernel.CreateFunctionFromMethod(() => new List { "Squirrel Steals Show", "Dog Wins Lottery" }, "GetLatestNewsTitles", "Retrieves latest news titles."), + kernel.CreateFunctionFromMethod(() => DateTime.UtcNow.ToString("R"), "GetCurrentUtcDateTime", "Retrieves the current date time in UTC."), + kernel.CreateFunctionFromMethod((string cityName, string currentDateTime) => + cityName switch + { + "Boston" => "61 and rainy", + "London" => "55 and cloudy", + "Miami" => "80 and sunny", + "Paris" => "60 and rainy", + "Tokyo" => "50 and sunny", + "Sydney" => "75 and sunny", + "Tel Aviv" => "80 and sunny", + _ => "31 and snowing", + }, "GetWeatherForCity", "Gets the current weather for the specified city"), + ]); + + return kernel; + } +} diff --git a/dotnet/samples/Concepts/README.md b/dotnet/samples/Concepts/README.md index deb3a6a43a20..0b4fb374b884 100644 --- a/dotnet/samples/Concepts/README.md +++ b/dotnet/samples/Concepts/README.md @@ -39,8 +39,9 @@ dotnet test -l "console;verbosity=detailed" --filter "FullyQualifiedName=ChatCom ### FunctionCalling - Examples on `Function Calling` with function call capable models -- [Gemini_FunctionCalling](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/FunctionCalling/Gemini_FunctionCalling.cs) - [FunctionCalling](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/FunctionCalling/FunctionCalling.cs) +- [Gemini_FunctionCalling](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/FunctionCalling/Gemini_FunctionCalling.cs) +- [AzureAIInference_FunctionCalling](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/FunctionCalling/AzureAIInference_FunctionCalling.cs) - [NexusRaven_HuggingFaceTextGeneration](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/FunctionCalling/NexusRaven_FunctionCalling.cs) - [MultipleFunctionsVsParameters](https://github.com/microsoft/semantic-kernel/blob/main/dotnet/samples/Concepts/FunctionCalling/MultipleFunctionsVsParameters.cs) diff --git a/dotnet/samples/GettingStartedWithProcesses/Step05/Step05_MapReduce.cs b/dotnet/samples/GettingStartedWithProcesses/Step05/Step05_MapReduce.cs index 144d4ab79d3f..ca8d33818d53 100644 --- a/dotnet/samples/GettingStartedWithProcesses/Step05/Step05_MapReduce.cs +++ b/dotnet/samples/GettingStartedWithProcesses/Step05/Step05_MapReduce.cs @@ -116,7 +116,7 @@ public async ValueTask ComputeAsync(KernelProcessStepContext context, string chu { Dictionary counts = []; - string[] words = chunk.Split([' ', '\n', '\r', '.', ',', '’'], StringSplitOptions.RemoveEmptyEntries); + string[] words = chunk.Split([" ", "\n", "\r", ".", ",", "’"], StringSplitOptions.RemoveEmptyEntries); foreach (string word in words) { if (s_notInteresting.Contains(word)) diff --git a/dotnet/src/Agents/OpenAI/Extensions/KernelFunctionExtensions.cs b/dotnet/src/Agents/OpenAI/Extensions/KernelFunctionExtensions.cs index c4acca58770f..1a4b6fc2fbf6 100644 --- a/dotnet/src/Agents/OpenAI/Extensions/KernelFunctionExtensions.cs +++ b/dotnet/src/Agents/OpenAI/Extensions/KernelFunctionExtensions.cs @@ -81,6 +81,11 @@ private static string ConvertType(Type? type) return "array"; } + if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) + { + return "date-time"; + } + return Type.GetTypeCode(type) switch { TypeCode.SByte or TypeCode.Byte or diff --git a/dotnet/src/Agents/UnitTests/OpenAI/Extensions/KernelFunctionExtensionsTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/Extensions/KernelFunctionExtensionsTests.cs index acf195840366..3710b4841ab3 100644 --- a/dotnet/src/Agents/UnitTests/OpenAI/Extensions/KernelFunctionExtensionsTests.cs +++ b/dotnet/src/Agents/UnitTests/OpenAI/Extensions/KernelFunctionExtensionsTests.cs @@ -58,7 +58,7 @@ public void TestFunction1() { } [KernelFunction] [Description("test description")] #pragma warning disable IDE0060 // Unused parameter for mock kernel function - public void TestFunction2(string p1, bool p2, int p3, string[] p4, ConsoleColor p5, OpenAIAssistantDefinition p6) { } + public void TestFunction2(string p1, bool p2, int p3, string[] p4, ConsoleColor p5, OpenAIAssistantDefinition p6, DateTime p7) { } #pragma warning restore IDE0060 // Unused parameter } } diff --git a/dotnet/src/Connectors/Connectors.AzureAIInference.UnitTests/Settings/AzureAIInferencePromptExecutionSettingsTests.cs b/dotnet/src/Connectors/Connectors.AzureAIInference.UnitTests/Settings/AzureAIInferencePromptExecutionSettingsTests.cs index c61a261e7d30..0ef1459b52d3 100644 --- a/dotnet/src/Connectors/Connectors.AzureAIInference.UnitTests/Settings/AzureAIInferencePromptExecutionSettingsTests.cs +++ b/dotnet/src/Connectors/Connectors.AzureAIInference.UnitTests/Settings/AzureAIInferencePromptExecutionSettingsTests.cs @@ -26,9 +26,9 @@ public void ItCreatesAzureAIInferenceExecutionSettingsWithCorrectDefaults() Assert.Null(executionSettings.ResponseFormat); Assert.Null(executionSettings.Seed); Assert.Null(executionSettings.MaxTokens); + Assert.Null(executionSettings.Tools); + Assert.Null(executionSettings.StopSequences); Assert.Empty(executionSettings.ExtensionData!); - Assert.Empty(executionSettings.Tools); - Assert.Empty(executionSettings.StopSequences!); } [Fact] diff --git a/dotnet/src/Connectors/Connectors.AzureAIInference/Settings/AzureAIInferencePromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.AzureAIInference/Settings/AzureAIInferencePromptExecutionSettings.cs index 5bab73330fa9..3146cb94fb78 100644 --- a/dotnet/src/Connectors/Connectors.AzureAIInference/Settings/AzureAIInferencePromptExecutionSettings.cs +++ b/dotnet/src/Connectors/Connectors.AzureAIInference/Settings/AzureAIInferencePromptExecutionSettings.cs @@ -153,7 +153,7 @@ public object? ResponseFormat /// A collection of textual sequences that will end completions generation. [JsonPropertyName("stop")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList StopSequences + public IList? StopSequences { get => this._stopSequences; set @@ -170,7 +170,7 @@ public IList StopSequences /// [JsonPropertyName("tools")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList Tools + public IList? Tools { get => this._tools; set @@ -229,8 +229,8 @@ public override PromptExecutionSettings Clone() NucleusSamplingFactor = this.NucleusSamplingFactor, MaxTokens = this.MaxTokens, ResponseFormat = this.ResponseFormat, - StopSequences = new List(this.StopSequences), - Tools = new List(this.Tools), + StopSequences = this.StopSequences is not null ? new List(this.StopSequences) : null, + Tools = this.Tools is not null ? new List(this.Tools) : null, Seed = this.Seed, ExtensionData = this.ExtensionData is not null ? new Dictionary(this.ExtensionData) : null, }; @@ -273,8 +273,8 @@ public static AzureAIInferencePromptExecutionSettings FromExecutionSettings(Prom private float? _nucleusSamplingFactor; private int? _maxTokens; private object? _responseFormat; - private IList _stopSequences = []; - private IList _tools = []; + private IList? _stopSequences; + private IList? _tools; private long? _seed; #endregion diff --git a/dotnet/src/Functions/Functions.OpenApi/Extensions/RestApiOperationExtensions.cs b/dotnet/src/Functions/Functions.OpenApi/Extensions/RestApiOperationExtensions.cs index 478306812f10..ad2a7a7c5101 100644 --- a/dotnet/src/Functions/Functions.OpenApi/Extensions/RestApiOperationExtensions.cs +++ b/dotnet/src/Functions/Functions.OpenApi/Extensions/RestApiOperationExtensions.cs @@ -183,6 +183,12 @@ private static List GetParametersFromPayloadMetadata(RestApiOp if (!property.Properties.Any()) { + // Assign an argument name (sanitized form of the property name) so that the parameter value look-up / resolution functionality in the RestApiOperationRunner + // class can find the value for the parameter by the argument name in the arguments dictionary. If the argument name is not assigned here, the resolution mechanism + // will try to find the parameter value by the parameter's original name. However, because the parameter was advertised with the sanitized name by the RestApiOperationExtensions.GetParameters + // method, no value will be found, and an exception will be thrown: "No argument is found for the 'customerid_contact@odata.bind' payload property." + property.ArgumentName ??= InvalidSymbolsRegex().Replace(parameterName, "_"); + var parameter = new RestApiParameter( name: parameterName, type: property.Type, diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index 7c0ada6d830c..c32a3811e6bb 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -175,7 +175,7 @@ public Task RunAsync( var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments); - return this.SendAsync(url, operation.Method, headers, Payload, Content, operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), options, cancellationToken); + return this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken); } #region private @@ -183,26 +183,24 @@ public Task RunAsync( /// /// Sends an HTTP request. /// + /// The REST API operation. /// The url to send request to. - /// The HTTP request method. /// Headers to include into the HTTP request. /// HTTP request payload. /// HTTP request content. - /// The dictionary of expected response schemas. /// Options for REST API operation run. /// The cancellation token. /// Response content and content type private async Task SendAsync( + RestApiOperation operation, Uri url, - HttpMethod method, IDictionary? headers = null, object? payload = null, HttpContent? requestContent = null, - IDictionary? expectedSchemas = null, RestApiOperationRunOptions? options = null, CancellationToken cancellationToken = default) { - using var requestMessage = new HttpRequestMessage(method, url); + using var requestMessage = new HttpRequestMessage(operation.Method, url); #if NET5_0_OR_GREATER requestMessage.Options.Set(OpenApiKernelFunctionContext.KernelFunctionContextKey, new OpenApiKernelFunctionContext(options?.Kernel, options?.KernelFunction, options?.KernelArguments)); @@ -237,9 +235,7 @@ private async Task SendAsync( { responseMessage = await this._httpClient.SendWithSuccessCheckAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - response = await this.ReadContentAndCreateOperationResponseAsync(requestMessage, responseMessage, payload, cancellationToken).ConfigureAwait(false); - - response.ExpectedSchema ??= GetExpectedSchema(expectedSchemas, responseMessage.StatusCode); + response = await this.BuildResponseAsync(operation, requestMessage, responseMessage, payload, cancellationToken).ConfigureAwait(false); return response; } @@ -352,7 +348,15 @@ private async Task ReadContentAndCreateOperationRespon mediaType = mediaTypeFallback; } - if (!this._payloadFactoryByMediaType.TryGetValue(mediaType!, out var payloadFactory)) + // Remove media type parameters, such as x-api-version, from the "text/plain; x-api-version=2.0" media type string. + mediaType = mediaType!.Split(';').First(); + + // Normalize the media type to lowercase and remove trailing whitespaces. +#pragma warning disable CA1308 // Normalize strings to uppercase + mediaType = mediaType!.ToLowerInvariant().Trim(); +#pragma warning restore CA1308 // Normalize strings to uppercase + + if (!this._payloadFactoryByMediaType.TryGetValue(mediaType, out var payloadFactory)) { throw new KernelException($"The media type {mediaType} of the {operation.Id} operation is not supported by {nameof(RestApiOperationRunner)}."); } @@ -562,5 +566,23 @@ private Uri BuildsOperationUrl(RestApiOperation operation, IDictionary + /// Builds the operation response. + /// + /// The REST API operation. + /// The HTTP request message. + /// The HTTP response message. + /// The payload sent in the HTTP request. + /// The cancellation token. + /// The operation response. + private async Task BuildResponseAsync(RestApiOperation operation, HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, object? payload, CancellationToken cancellationToken) + { + var response = await this.ReadContentAndCreateOperationResponseAsync(requestMessage, responseMessage, payload, cancellationToken).ConfigureAwait(false); + + response.ExpectedSchema ??= GetExpectedSchema(operation.Responses.ToDictionary(item => item.Key, item => item.Value.Schema), responseMessage.StatusCode); + + return response; + } + #endregion } diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserExtensionsTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserExtensionsTests.cs index 88cb52d183e6..52fdd6371496 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserExtensionsTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserExtensionsTests.cs @@ -91,7 +91,7 @@ public async Task ItCanParseMediaTypeAsync(string documentName) // Assert. Assert.NotNull(restApi.Operations); - Assert.Equal(7, restApi.Operations.Count); + Assert.Equal(8, restApi.Operations.Count); var operation = restApi.Operations.Single(o => o.Id == "Joke"); Assert.NotNull(operation); Assert.Equal("application/json; x-api-version=2.0", operation.Payload?.MediaType); diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs index 9313297ace66..ae60700f5811 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV20Tests.cs @@ -236,7 +236,7 @@ public async Task ItCanExtractAllPathsAsOperationsAsync() var restApi = await this._sut.ParseAsync(this._openApiDocument); // Assert - Assert.Equal(6, restApi.Operations.Count); + Assert.Equal(7, restApi.Operations.Count); } [Fact] @@ -419,7 +419,7 @@ public async Task ItCanParsePropertiesOfObjectDataTypeAsync() public async Task ItCanFilterOutSpecifiedOperationsAsync() { // Arrange - var operationsToExclude = new[] { "Excuses", "TestDefaultValues", "OpenApiExtensions", "TestParameterDataTypes" }; + string[] operationsToExclude = ["Excuses", "TestDefaultValues", "OpenApiExtensions", "TestParameterDataTypes", "TestParameterNamesSanitization"]; var options = new OpenApiDocumentParserOptions { diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs index 02b3d363ebfb..b9c7e96378ce 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV30Tests.cs @@ -237,7 +237,7 @@ public async Task ItCanExtractAllPathsAsOperationsAsync() var restApi = await this._sut.ParseAsync(this._openApiDocument); // Assert - Assert.Equal(7, restApi.Operations.Count); + Assert.Equal(8, restApi.Operations.Count); } [Fact] diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs index 5fc59c70a8f9..0e51bfe35b24 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiDocumentParserV31Tests.cs @@ -237,7 +237,7 @@ public async Task ItCanExtractAllPathsAsOperationsAsync() var restApi = await this._sut.ParseAsync(this._openApiDocument); // Assert - Assert.Equal(7, restApi.Operations.Count); + Assert.Equal(8, restApi.Operations.Count); } [Fact] diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs index 2242f5032610..c85002493181 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/OpenApiKernelPluginFactoryTests.cs @@ -8,6 +8,7 @@ using System.Net.Http; using System.Net.Mime; using System.Text; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Plugins.OpenApi; @@ -353,7 +354,7 @@ public async Task ItShouldHandleEmptyOperationNameAsync() var plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("fakePlugin", content, this._executionParameters); // Assert - Assert.Equal(7, plugin.Count()); + Assert.Equal(8, plugin.Count()); Assert.True(plugin.TryGetFunction("GetSecretsSecretname", out var _)); } @@ -372,7 +373,7 @@ public async Task ItShouldHandleNullOperationNameAsync() var plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("fakePlugin", content, this._executionParameters); // Assert - Assert.Equal(7, plugin.Count()); + Assert.Equal(8, plugin.Count()); Assert.True(plugin.TryGetFunction("GetSecretsSecretname", out var _)); } @@ -517,6 +518,94 @@ public void ItCreatesPluginFromOpenApiSpecificationModel() Assert.Same(operations[0], function.Metadata.AdditionalProperties["operation"]); } + [Fact] + public async Task ItShouldResolveArgumentsByParameterNamesAsync() + { + // Arrange + using var messageHandlerStub = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(messageHandlerStub, false); + + this._executionParameters.EnableDynamicPayload = true; + this._executionParameters.HttpClient = httpClient; + + var arguments = new KernelArguments + { + ["string_parameter"] = "fake-secret-name", + ["boolean@parameter"] = true, + ["integer+parameter"] = 6, + ["float?parameter"] = 23.4f + }; + + var kernel = new Kernel(); + + var openApiDocument = ResourcePluginsProvider.LoadFromResource("documentV3_0.json"); + + var plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("fakePlugin", openApiDocument, this._executionParameters); + + // Act + var result = await kernel.InvokeAsync(plugin["TestParameterNamesSanitization"], arguments); + + // Assert path and query parameters added to the request uri + Assert.NotNull(messageHandlerStub.RequestUri); + Assert.Equal("https://my-key-vault.vault.azure.net/test-parameter-names-sanitization/fake-secret-name?boolean@parameter=true", messageHandlerStub.RequestUri.AbsoluteUri); + + // Assert header parameters added to the request + Assert.Equal("6", messageHandlerStub.RequestHeaders!.GetValues("integer+parameter").First()); + + // Assert payload parameters added to the request + var messageContent = messageHandlerStub.RequestContent; + Assert.NotNull(messageContent); + + var deserializedPayload = await JsonNode.ParseAsync(new MemoryStream(messageContent)); + Assert.NotNull(deserializedPayload); + + Assert.Equal(23.4f, deserializedPayload["float?parameter"]!.GetValue()); + } + + [Fact] + public async Task ItShouldResolveArgumentsBySanitizedParameterNamesAsync() + { + // Arrange + using var messageHandlerStub = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(messageHandlerStub, false); + + this._executionParameters.EnableDynamicPayload = true; + this._executionParameters.HttpClient = httpClient; + + var arguments = new KernelArguments + { + ["string_parameter"] = "fake-secret-name", // Original parameter name - string-parameter + ["boolean_parameter"] = true, // Original parameter name - boolean@parameter + ["integer_parameter"] = 6, // Original parameter name - integer+parameter + ["float_parameter"] = 23.4f // Original parameter name - float?parameter + }; + + var kernel = new Kernel(); + + var openApiDocument = ResourcePluginsProvider.LoadFromResource("documentV3_0.json"); + + var plugin = await OpenApiKernelPluginFactory.CreateFromOpenApiAsync("fakePlugin", openApiDocument, this._executionParameters); + + // Act + var result = await kernel.InvokeAsync(plugin["TestParameterNamesSanitization"], arguments); + + // Assert path and query parameters added to the request uri + Assert.NotNull(messageHandlerStub.RequestUri); + Assert.Equal("https://my-key-vault.vault.azure.net/test-parameter-names-sanitization/fake-secret-name?boolean@parameter=true", messageHandlerStub.RequestUri.AbsoluteUri); + + // Assert header parameters added to the request + Assert.Equal("6", messageHandlerStub.RequestHeaders!.GetValues("integer+parameter").First()); + + // Assert payload parameters added to the request + var messageContent = messageHandlerStub.RequestContent; + Assert.NotNull(messageContent); + + var deserializedPayload = await JsonNode.ParseAsync(new MemoryStream(messageContent)); + Assert.NotNull(deserializedPayload); + + Assert.Equal(23.4f, deserializedPayload["float?parameter"]!.GetValue()); + } + /// /// Generate theory data for ItAddSecurityMetadataToOperationAsync /// diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs index fd48bbcb6a50..764e5e2b4f07 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs @@ -1705,6 +1705,44 @@ public async Task ItShouldReturnExpectedSchemaAsync(string expectedStatusCode, p Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(result.ExpectedSchema)); } + [Theory] + [InlineData("application/json;x-api-version=2.0", "application/json")] + [InlineData("application/json ; x-api-version=2.0", "application/json")] + [InlineData(" application/JSON; x-api-version=2.0", "application/json")] + [InlineData(" TEXT/PLAIN ; x-api-version=2.0", "text/plain")] + public async Task ItShouldNormalizeContentTypeArgumentAsync(string actualContentType, string normalizedContentType) + { + // Arrange + this._httpMessageHandlerStub.ResponseToReturn.Content = new StringContent("fake-content", Encoding.UTF8, MediaTypeNames.Text.Plain); + + var operation = new RestApiOperation( + id: "fake-id", + servers: [new RestApiServer("https://fake-random-test-host")], + path: "fake-path", + method: HttpMethod.Post, + description: "fake-description", + parameters: [], + responses: new Dictionary(), + securityRequirements: [], + payload: null + ); + + var arguments = new KernelArguments + { + { "payload", "fake-input-value" }, + { "content-type", actualContentType }, + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, enableDynamicPayload: false); + + // Act + var result = await sut.RunAsync(operation, arguments); + + // Assert + Assert.NotNull(this._httpMessageHandlerStub.ContentHeaders); + Assert.Contains(this._httpMessageHandlerStub.ContentHeaders, h => h.Key == "Content-Type" && h.Value.Any(h => h.StartsWith(normalizedContentType, StringComparison.InvariantCulture))); + } + /// /// Disposes resources used by this class. /// diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV2_0.json b/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV2_0.json index cc71b0f46737..cb83ae5a809a 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV2_0.json +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV2_0.json @@ -434,6 +434,63 @@ }, "summary": "Get secret" } + }, + "/test-parameter-names-sanitization/{string-parameter}": { + "put": { + "summary": "Operation to test parameter names sanitization.", + "description": "Operation to test that forbidden characters in parameter names are sanitized.", + "operationId": "TestParameterNamesSanitization", + "consumes": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "string-parameter", + "required": true, + "type": "string", + "default": "string-value" + }, + { + "in": "query", + "name": "boolean@parameter", + "required": true, + "type": "boolean", + "default": true + }, + { + "in": "header", + "name": "integer+parameter", + "required": true, + "type": "integer", + "format": "int32", + "default": 281 + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "required": [ + "float?parameter" + ], + "type": "object", + "properties": { + "float?parameter": { + "format": "float", + "default": 12.01, + "type": "number" + } + } + } + } + ], + "responses": { + "200": { + "description": "The OK response" + } + } + } } }, "produces": [], diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_0.json b/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_0.json index a2990fb86f90..abc7eef32825 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_0.json +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_0.json @@ -486,6 +486,67 @@ } } } + }, + "/test-parameter-names-sanitization/{string-parameter}": { + "put": { + "summary": "Operation to test parameter names sanitization.", + "description": "Operation to test that forbidden characters in parameter names are sanitized.", + "operationId": "TestParameterNamesSanitization", + "parameters": [ + { + "name": "string-parameter", + "in": "path", + "required": true, + "schema": { + "type": "string", + "default": "string-value" + } + }, + { + "name": "boolean@parameter", + "in": "query", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + }, + { + "name": "integer+parameter", + "in": "header", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "default": 281 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "float?parameter": { + "type": "number", + "format": "float", + "default": 12.01 + } + }, + "required": [ "float?parameter" ] + } + } + }, + "required": true, + "x-bodyName": "body" + }, + "responses": { + "200": { + "description": "The OK response" + } + } + } } }, "components": { diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_1.yaml b/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_1.yaml index 8c250db741cb..3bf24812684a 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_1.yaml +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/TestPlugins/documentV3_1.yaml @@ -140,7 +140,7 @@ paths: type: string /FunPlugin/Joke: post: - summary: Gneerate a funny joke + summary: Generate a funny joke operationId: Joke requestBody: description: Joke subject @@ -244,6 +244,8 @@ paths: description: The OK response /api-with-open-api-extensions: get: + summary: Get API with open-api specification extensions + description: 'For more information on specification extensions see the specification extensions section of the open api spec: https://swagger.io/specification/v3/' operationId: OpenApiExtensions responses: '200': @@ -318,6 +320,48 @@ paths: responses: '200': description: The OK response + '/test-parameter-names-sanitization/{string-parameter}': + put: + summary: Operation to test parameter names sanitization. + description: Operation to test that forbidden characters in parameter names are sanitized. + operationId: TestParameterNamesSanitization + parameters: + - name: string-parameter + in: path + required: true + schema: + type: string + default: string-value + - name: boolean@parameter + in: query + required: true + schema: + type: boolean + default: true + - name: integer+parameter + in: header + required: true + schema: + type: integer + format: int32 + default: 281 + requestBody: + content: + application/json: + schema: + required: + - float?parameter + type: object + properties: + float?parameter: + type: number + format: float + default: 12.01 + required: true + x-bodyName: body + responses: + '200': + description: The OK response components: securitySchemes: oauth2_auth: diff --git a/python/samples/concepts/chat_completion/simple_chatbot_store_metadata.py b/python/samples/concepts/chat_completion/simple_chatbot_store_metadata.py index 44484aed7122..0c59ff9dc5ec 100644 --- a/python/samples/concepts/chat_completion/simple_chatbot_store_metadata.py +++ b/python/samples/concepts/chat_completion/simple_chatbot_store_metadata.py @@ -32,7 +32,7 @@ # Create a chat history object with the system message. chat_history = ChatHistory(system_message=system_message) -# Configure the store amd metadata settings for the chat completion service. +# Configure the store and metadata settings for the chat completion service. request_settings.store = True request_settings.metadata = {"chatbot": "Mosscap"}